fix: Embed - Better support for layout config(without flash of default toggle) (#9714)

This commit is contained in:
Hariom Balhara
2023-06-26 17:24:19 +02:00
committed by GitHub
parent 6adaf0d3e8
commit 4ada428bcb
8 changed files with 226 additions and 48 deletions
+80 -18
View File
@@ -10,7 +10,7 @@ import { components } from "react-select";
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { APP_NAME, EMBED_LIB_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import {
@@ -38,13 +38,21 @@ const enum Theme {
dark = "dark",
}
const EMBED_CAL_ORIGIN = WEBAPP_URL;
const EMBED_CAL_JS_URL = `${WEBAPP_URL}/embed/embed.js`;
type PreviewState = {
inline: {
width: string;
height: string;
};
theme: Theme;
floatingPopup: Record<string, string | boolean | undefined>;
floatingPopup: {
config?: {
layout: BookerLayouts;
};
[key: string]: string | boolean | undefined | Record<string, string>;
};
elementClick: Record<string, string>;
palette: {
brandColor: string;
@@ -175,14 +183,24 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
export default function MyApp() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
${uiInstructionCode}
})();
}, [])
return <Cal calLink="${calLink}" style={{width:"${width}",height:"${height}",overflow:"scroll"}} />;
return <Cal
calLink="${calLink}"
style={{width:"${width}",height:"${height}",overflow:"scroll"}}
${previewState.layout ? "config={{layout: '" + previewState.layout + "'}}" : ""}${
IS_SELF_HOSTED
? `
calOrigin="${EMBED_CAL_ORIGIN}"
calJsUrl="${EMBED_CAL_JS_URL}"`
: ""
}
/>;
};`;
},
"floating-popup": ({
@@ -193,38 +211,60 @@ function MyComponent() {
uiInstructionCode: string;
}) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
export default function App() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode}
})();
}, [])
};`;
},
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
"element-click": ({
calLink,
uiInstructionCode,
previewState,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState;
}) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
export default function App() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
${uiInstructionCode}
})();
}, [])
return <button data-cal-link="${calLink}" />;
return <button
data-cal-link="${calLink}"${IS_SELF_HOSTED ? `\ndata-cal-origin="${EMBED_CAL_ORIGIN}"` : ""}
${`data-cal-config='${JSON.stringify({
layout: previewState.layout,
})}'`}
>Click me</button>;
};`;
},
},
HTML: {
inline: ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
inline: ({
calLink,
uiInstructionCode,
previewState,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState;
}) => {
return code`Cal("inline", {
elementOrSelector:"#my-cal-inline",
calLink: "${calLink}"
calLink: "${calLink}",
layout: "${previewState.layout}"
});
${uiInstructionCode}`;
@@ -240,8 +280,22 @@ ${uiInstructionCode}`;
return code`Cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode}`;
},
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
return code`// Important: Make sure to add \`data-cal-link="${calLink}"\` attribute to the element you want to open Cal on click
"element-click": ({
calLink,
uiInstructionCode,
previewState,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState;
}) => {
return code`
// Important: Please add following attributes to the element you want to open Cal on click
// \`data-cal-link="${calLink}"\`
// \`data-cal-config='${JSON.stringify({
layout: previewState.layout,
})}'\`
${uiInstructionCode}`;
},
},
@@ -298,6 +352,7 @@ const getEmbedTypeSpecificString = ({
} else if (embedType === "floating-popup") {
const floatingButtonArg = {
calLink,
...(IS_SELF_HOSTED ? { calOrigin: EMBED_CAL_ORIGIN } : null),
...previewState.floatingPopup,
};
return frameworkCodes[embedType]({
@@ -710,13 +765,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
height: "100%",
},
theme: Theme.auto,
layout: BookerLayouts.MONTH_VIEW,
floatingPopup: {},
elementClick: {},
hideEventTypeDetails: false,
palette: {
brandColor: "#000000",
},
layout: BookerLayouts.MONTH_VIEW,
});
const close = () => {
@@ -778,8 +833,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
name: "ui",
arg: {
theme: previewState.theme,
hideEventTypeDetails: previewState.hideEventTypeDetails,
layout: previewState.layout,
hideEventTypeDetails: previewState.hideEventTypeDetails,
styles: {
branding: {
...previewState.palette,
@@ -1102,8 +1157,15 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
return;
}
setPreviewState((previewState) => {
const config = {
...(previewState.floatingPopup.config ?? {}),
layout: option.value,
};
return {
...previewState,
floatingPopup: {
config,
},
layout: option.value,
};
});
+3 -3
View File
@@ -100,7 +100,7 @@ plugins.push(withAxiom);
const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`;
const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
/** @type {import("next").NextConfig} */
@@ -312,11 +312,11 @@ const nextConfig = {
afterFiles.push(
...[
{
source: `/:user((?!${pages.join("|")}).*)/:type/embed`,
source: embedUserTypeRouteRegExp,
destination: "/new-booker/:user/:type/embed",
},
{
source: "/team/:slug/:type/embed",
source: embedTeamTypeRouteRegExp,
destination: "/new-booker/team/:slug/:type/embed",
},
]
+8 -2
View File
@@ -47,8 +47,8 @@ describe('next.config.js - RegExp', ()=>{
// privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?<link>[^/]+)/(?<slug>((?!book$)[^/]+))");
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("|")}).*))/(?<type>[^/]+)/embed`);
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`;
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("/|")})[^/]*))/(?<type>[^/]+)/embed`);
// embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>[^/]+)/embed");
@@ -94,6 +94,12 @@ describe('next.config.js - RegExp', ()=>{
type:'30'
})
// Edgecase of username starting with team also works
expect(embedUserTypeRouteRegExp.exec('/teampro/30/embed')?.groups).toContain({
user: 'teampro',
type: '30'
})
expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({
slug: 'seeded',
type: '30'
+11 -1
View File
@@ -168,7 +168,9 @@
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)](Default Namespace)</a>
</h3>
<button onclick="Cal('ui',{theme:'light'})">Toggle to Light</button>
<button onclick="Cal('ui',{layout:'week_view'})">Toggle to Week View</button>
<button onclick="Cal('ui',{layout:'month_view'})">Toggle to Month View</button>
<button onclick="Cal('ui',{layout:'column_view'})">Toggle to Column View</button>
<i class="last-action"> You would see last Booking page action in my place </i>
<div>
<div class="place" style="width: 100%"></div>
@@ -253,6 +255,14 @@
<i>Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace</i>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-weekView">
<h3><a href="?only=ns:weekView">Test Weekly View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-columnView">
<h3><a href="?only=ns:columnView">Test Column View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<script src="./playground.ts"></script>
</body>
</html>
+56
View File
@@ -344,3 +344,59 @@ if (only === "all" || only == "ns:floatingButton") {
},
});
}
if (only === "all" || only == "ns:weekView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "weekView", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.weekView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-weekView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-weekView-iframe",
},
layout: "week_view",
},
}
);
Cal.ns.weekView("on", {
action: "*",
callback,
});
}
if (only === "all" || only == "ns:columnView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "columnView", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.columnView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-columnView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-columnView-iframe",
},
layout: "column_view",
},
}
);
Cal.ns.columnView("on", {
action: "*",
callback,
});
}
+41 -10
View File
@@ -3,7 +3,7 @@ import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { BookerStore } from "@calcom/features/bookings/Booker/store";
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
@@ -23,6 +23,9 @@ export type UiConfig = {
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
@@ -37,7 +40,11 @@ const embedStore = {
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
theme: undefined as UiConfig["theme"],
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
setUiConfig: undefined as ((arg0: UiConfig) => void) | undefined,
/**
* We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components.
*/
setUiConfig: [] as ((arg0: UiConfig) => void)[],
layout: BookerLayouts,
};
declare global {
@@ -183,9 +190,21 @@ export const useEmbedTheme = () => {
return theme;
};
/**
* It serves following purposes
* - Gives consistent values for ui config even after Soft Navigation. When a new React component mounts, it would ensure that the component get's the correct value of ui config
* - Ensures that all the components using useEmbedUiConfig are updated when ui config changes. It is done by maintaining a list of all non-stale setters.
*/
export const useEmbedUiConfig = () => {
const [uiConfig, setUiConfig] = useState(embedStore.uiConfig || {});
embedStore.setUiConfig = setUiConfig;
embedStore.setUiConfig.push(setUiConfig);
useEffect(() => {
return () => {
const foundAtIndex = embedStore.setUiConfig.findIndex((item) => item === setUiConfig);
// Keep removing the setters that are stale
embedStore.setUiConfig.splice(foundAtIndex, 1);
};
});
return uiConfig;
};
@@ -307,19 +326,18 @@ const methods = {
}
}
// Set the value here so that if setUiConfig state isn't available and later it's defined,it uses this value
embedStore.uiConfig = uiConfig;
// Merge new values over the old values
uiConfig = {
...embedStore.uiConfig,
...uiConfig,
};
if (uiConfig.cssVarsPerTheme) {
window.CalEmbed.applyCssVars(uiConfig.cssVarsPerTheme);
}
if (embedStore.setUiConfig) {
embedStore.setUiConfig(uiConfig);
}
if (uiConfig.layout) {
window.CalEmbed.setLayout?.(uiConfig.layout);
runAllUiSetters(uiConfig);
}
setEmbedStyles(stylesConfig || {});
@@ -440,6 +458,13 @@ if (isBrowser) {
log("Embed SDK loaded", { isEmbed: window?.isEmbed?.() || false });
const url = new URL(document.URL);
embedStore.theme = window?.getEmbedTheme?.();
embedStore.uiConfig = {
// TODO: Add theme as well here
// theme:
layout: url.searchParams.get("layout") as BookerLayouts,
};
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
log("Initializing embed-iframe");
// HACK
@@ -492,3 +517,9 @@ if (isBrowser) {
});
}
}
function runAllUiSetters(uiConfig: UiConfig) {
// Update EmbedStore so that when a new react component mounts, useEmbedUiConfig can get the persisted value from embedStore.uiConfig
embedStore.uiConfig = uiConfig;
embedStore.setUiConfig.forEach((setUiConfig) => setUiConfig(uiConfig));
}
+13
View File
@@ -1,4 +1,6 @@
/// <reference types="../env" />
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
@@ -125,10 +127,21 @@ 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}`;
theme?: EmbedThemeConfig;
};
+14 -14
View File
@@ -1,11 +1,11 @@
import { LazyMotion, domAnimation, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
import { useEffect, useRef, useMemo } from "react";
import { useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import classNames from "@calcom/lib/classNames";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
@@ -23,7 +23,6 @@ import { useBookerStore, useInitializeBookerStore } from "./store";
import type { BookerLayout, BookerProps } from "./types";
import { useEvent } from "./utils/event";
import { validateLayout } from "./utils/layout";
import { getQueryParam } from "./utils/query-param";
import { useBrandColors } from "./utils/use-brand-colors";
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
@@ -46,10 +45,14 @@ const BookerComponent = ({
const rescheduleUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const event = useEvent();
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
if (typeof window !== "undefined") {
window.CalEmbed.setLayout = setLayout;
}
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
const isEmbed = useIsEmbed();
const embedUiConfig = useEmbedUiConfig();
// In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout
const layout = isEmbed ? validateLayout(embedUiConfig.layout) || _layout : _layout;
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
@@ -60,13 +63,10 @@ const BookerComponent = ({
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings;
const animationScope = useBookerResizeAnimation(layout, bookerState);
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
// We only want the initial url value, that's why we memo it. The embed seems to change the url, which sometimes drops
// the layout query param.
const layoutFromQueryParam = useMemo(() => validateLayout(getQueryParam("layout") as BookerLayouts), []);
// I would expect isEmbed to be not needed here as it's handled in derived variable layout, but somehow removing it breaks the views.
const defaultLayout = isEmbed
? layoutFromQueryParam || BookerLayouts.MONTH_VIEW
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
: bookerLayouts.defaultLayout;
useBrandColors({
@@ -107,7 +107,6 @@ const BookerComponent = ({
}
}, [layout]);
const embedUiConfig = useEmbedUiConfig();
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
if (event.isSuccess && !event.data) {
@@ -149,7 +148,8 @@ const BookerComponent = ({
!isEmbed && "sm:transition-[width] sm:duration-300",
isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-booker sm:border-booker-width",
!isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-subtle",
layout === BookerLayouts.MONTH_VIEW && isEmbed && "mt-20"
// We don't want any margins for Embed. Any margin needed should be added by Embed user.
layout === BookerLayouts.MONTH_VIEW && isEmbed && "mt-0"
)}>
<AnimatePresence>
<BookerSection