Embedded Routing Forms - Part1 (#3530)

* Support dark theme in routing form

* Fix Embed detection

* Add routing form embed example

* Better rendering support in dark mode for react-select

* Fix more theme issues

* Added test for Routing Form Embed

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara 2022-07-28 16:20:25 +05:30 committed by GitHub
parent e871781079
commit 1be5510c5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 447 additions and 135 deletions

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
import ReactSelect, { components, GroupBase, Props, InputProps, SingleValue, MultiValue } from "react-select";
import classNames from "@lib/classNames";
import useTheme from "@lib/hooks/useTheme";
export type SelectProps<
Option,
@ -27,6 +28,54 @@ function Select<
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
const { resolvedTheme, forcedTheme, isReady } = useTheme();
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
const darkThemeColors = {
/** Dark Theme starts */
//primary - Border when selected and Selected Option background
primary: "rgb(41 41 41 / var(--tw-border-opacity))",
neutral0: "rgb(62 62 62 / var(--tw-bg-opacity))",
// Down Arrow hover color
neutral5: "white",
neutral10: "rgb(41 41 41 / var(--tw-border-opacity))",
// neutral20 - border color + down arrow default color
neutral20: "rgb(41 41 41 / var(--tw-border-opacity))",
// neutral30 - hover border color
neutral30: "rgb(41 41 41 / var(--tw-border-opacity))",
neutral40: "white",
danger: "white",
// Cross button in multiselect
dangerLight: "rgb(41 41 41 / var(--tw-border-opacity))",
// neutral50 - MultiSelect - "Select Text" color
neutral50: "white",
// neutral60 - Down Arrow color
neutral60: "white",
neutral70: "red",
// neutral80 - Selected option
neutral80: "white",
neutral90: "blue",
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
/** Dark Theme ends */
};
// Till we know in JS the theme is ready, we can't render react-select as it would render with light theme instead
if (!isReady) {
return <input type="text" className={className} />;
}
return (
<ReactSelect
theme={(theme) => ({
@ -34,10 +83,16 @@ function Select<
borderRadius: 2,
colors: {
...theme.colors,
primary: "var(--brand-color)",
...(hasDarkTheme
? darkThemeColors
: {
/** Light Theme starts */
primary: "var(--brand-color)",
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
/** Light Theme Ends */
}),
},
})}
styles={{

View File

@ -1,6 +1,7 @@
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import { NextRouter } from "next/router";
import { ComponentProps, ReactNode } from "react";
import { trpc } from "@calcom/trpc/react";
@ -15,7 +16,10 @@ const I18nextAdapter = appWithTranslation<NextJsAppProps & { children: React.Rea
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<NextAppProps, "Component"> & {
Component: NextAppProps["Component"] & { requiresLicense?: boolean; isThemeSupported?: boolean };
Component: NextAppProps["Component"] & {
requiresLicense?: boolean;
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
};
/** Will be defined only is there was an error */
err?: Error;
};

View File

@ -1,5 +1,5 @@
import { useTheme as useNextTheme } from "next-themes";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useEmbedTheme } from "@calcom/embed-core/embed-iframe";
import { Maybe } from "@calcom/trpc/server";
@ -7,7 +7,8 @@ import { Maybe } from "@calcom/trpc/server";
// makes sure the ui doesn't flash
export default function useTheme(theme?: Maybe<string>) {
theme = theme || "system";
const { theme: currentTheme, setTheme } = useNextTheme();
const { resolvedTheme, setTheme, forcedTheme } = useNextTheme();
const [isReady, setIsReady] = useState<boolean>(false);
const embedTheme = useEmbedTheme();
// Embed UI configuration takes more precedence over App Configuration
theme = embedTheme || theme;
@ -16,10 +17,13 @@ export default function useTheme(theme?: Maybe<string>) {
if (theme) {
setTheme(theme);
}
setIsReady(true);
}, [theme, setTheme]);
return {
currentTheme,
resolvedTheme,
setTheme,
isReady,
forcedTheme,
};
}

View File

@ -17,7 +17,6 @@ import LicenseRequired from "@ee/components/LicenseRequired";
import AppProviders, { AppProps } from "@lib/app-providers";
import { seoConfig } from "@lib/config/next-seo.config";
import useTheme from "@lib/hooks/useTheme";
import I18nLanguageHandler from "@components/I18nLanguageHandler";
@ -28,13 +27,28 @@ import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps, err, router } = props;
let pageStatus = "200";
if (router.pathname === "/404") {
pageStatus = "404";
} else if (router.pathname === "/500") {
pageStatus = "500";
}
const forcedTheme = Component.isThemeSupported ? undefined : "light";
let isThemeSupported = null;
if (typeof Component.isThemeSupported === "function") {
isThemeSupported = Component.isThemeSupported({ router });
} else {
isThemeSupported = Component.isThemeSupported;
}
const forcedTheme = isThemeSupported ? undefined : "light";
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
// One such example is our Embeds Demo and Testing page at http://localhost:3100
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null;
const storageKey = typeof embedNamespace === "string" ? `embed-theme-${embedNamespace}` : "theme";
return (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<ContractsProvider>
@ -46,7 +60,11 @@ function MyApp(props: AppProps) {
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</Head>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<ThemeProvider enableColorScheme={false} forcedTheme={forcedTheme} attribute="class">
<ThemeProvider
enableColorScheme={false}
storageKey={storageKey}
forcedTheme={forcedTheme}
attribute="class">
{Component.requiresLicense ? (
<LicenseRequired>
<Component {...pageProps} err={err} />

View File

@ -2,6 +2,24 @@ import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps
type Props = Record<string, unknown> & DocumentProps;
function toRunBeforeReactOnClient() {
window.sessionStorage.setItem("calEmbedMode", String(location.search.includes("embed=")));
window.isEmbed = () => {
return window.sessionStorage.getItem("calEmbedMode") === "true";
};
window.getEmbedTheme = () => {
const url = new URL(document.URL);
return url.searchParams.get("theme") as "dark" | "light";
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
}
class MyDocument extends Document<Props> {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
@ -31,13 +49,12 @@ class MyDocument extends Document<Props> {
crossOrigin="anonymous"
/>
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body */}
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
Persist the embed mode in sessionStorage because query param might get lost during browsing.
*/}
<script
dangerouslySetInnerHTML={{
__html: `
window.isEmbed = ()=> {
return location.search.includes("embed=")
}`,
__html: `(${toRunBeforeReactOnClient.toString()})()`,
}}
/>
</Head>

View File

@ -1,5 +1,5 @@
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { NextRouter, useRouter } from "next/router";
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing_forms/pages/app-routing.config";
import prisma from "@calcom/prisma";
@ -31,7 +31,7 @@ function getRoute(appName: string, pages: string[]) {
notFound: false;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Component: (props: any) => JSX.Element;
Component: ((props: any) => JSX.Element) & { isThemeSupported?: boolean };
getServerSideProps: AppGetServerSideProps;
};
if (!appPage) {
@ -59,7 +59,13 @@ export default function AppPage(props: inferSSRProps<typeof getServerSideProps>)
return <route.Component {...componentProps} />;
}
AppPage.isThemeSupported = true;
AppPage.isThemeSupported = ({ router }: { router: NextRouter }) => {
const route = getRoute(router.query.slug as string, router.query.pages as string[]);
if (route.notFound) {
return false;
}
return route.Component.isThemeSupported;
};
export async function getServerSideProps(
context: GetServerSidePropsContext<{

View File

@ -52,7 +52,7 @@ const TextWidget = (props: TextWidgetProps & { type?: string }) => {
return (
<input
type={type}
className="flex flex-grow border-gray-300 text-sm"
className="flex flex-grow border-gray-300 text-sm dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
value={textValue}
placeholder={placeholder}
disabled={readonly}
@ -68,7 +68,7 @@ function NumberWidget({ value, setValue, ...remainingProps }: NumberWidgetProps)
<Input
name="query-builder"
type="number"
className="mt-0"
className="mt-0 border-gray-300 text-sm dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
value={value}
onChange={(e) => {
setValue(e.target.value);
@ -102,7 +102,7 @@ const MultiSelectWidget = ({
return (
<Select
className="block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
className="block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
menuPosition="fixed"
onChange={(items) => {
setValue(items?.map((item) => item.value));
@ -136,7 +136,7 @@ function SelectWidget({
return (
<Select
className="data-testid-select block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
className="data-testid-select block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
menuPosition="fixed"
onChange={(item) => {
if (!item) {

View File

@ -6,12 +6,17 @@ import { Utils as QbUtils } from "react-awesome-query-builder";
import { Toaster } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import CustomBranding from "@calcom/lib/CustomBranding";
import classNames from "@calcom/lib/classNames";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import useTheme from "@lib/hooks/useTheme";
import { getSerializableForm } from "../../utils";
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
@ -27,9 +32,11 @@ type Form = inferSSRProps<typeof getServerSideProps>["form"];
type Route = NonNullable<Form["routes"]>[0];
function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
function RoutingForm({ form, profile }: inferSSRProps<typeof getServerSideProps>) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
const isEmbed = useIsEmbed();
useTheme(profile.theme);
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
@ -71,7 +78,7 @@ function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
} else if (decidedAction.type === "externalRedirectUrl") {
window.location.href = decidedAction.value;
}
showToast("Form submitted successfully! Redirecting now ...", "success");
// showToast("Form submitted successfully! Redirecting now ...", "success");
},
onError: (e) => {
if (e?.message) {
@ -80,7 +87,7 @@ function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
if (e?.data?.code === "CONFLICT") {
return void showToast("Form already submitted", "error");
}
showToast("Something went wrong", "error");
// showToast("Something went wrong", "error");
},
});
@ -93,94 +100,104 @@ function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
onSubmit(response);
};
return !customPageMessage ? (
<>
<Head>
<title>{form.name} | Cal.com Forms</title>
</Head>
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="mx-0 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:-mx-4 sm:px-8">
<Toaster position="bottom-right" />
return (
<div>
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<form onSubmit={handleOnSubmit}>
<div className="mb-8">
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
{form.name}
</h1>
{form.description ? (
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{form.description}</p>
) : null}
</div>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
<div>
{!customPageMessage ? (
<>
<Head>
<title>{form.name} | Cal.com Forms</title>
</Head>
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main border-bookinglightest mx-0 rounded-md bg-white p-4 py-6 dark:bg-gray-800 sm:-mx-4 sm:px-8 sm:dark:border-gray-600 md:border">
<Toaster position="bottom-right" />
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700">
{field.label}
</label>
<form onSubmit={handleOnSubmit}>
<div className="mb-8">
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900 dark:text-white">
{form.name}
</h1>
{form.description ? (
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4 dark:text-white">
{form.description}
</p>
) : null}
</div>
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700 dark:text-white">
{field.label}
</label>
</div>
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
</div>
</div>
);
})}
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
loading={responseMutation.isLoading}
type="submit"
className="dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand relative inline-flex items-center rounded-sm border border-transparent px-3 py-2 text-sm font-medium hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
Submit
</Button>
</div>
</div>
);
})}
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
loading={responseMutation.isLoading}
type="submit"
className="dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand relative inline-flex items-center rounded-sm border border-transparent px-3 py-2 text-sm font-medium hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
Submit
</Button>
</form>
</div>
</div>
</form>
</div>
</>
) : (
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
<div>{customPageMessage}</div>
</div>
</div>
</div>
</div>
</div>
</>
) : (
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
<div>{customPageMessage}</div>
</div>
)}
</div>
</div>
);
@ -216,7 +233,10 @@ function processRoute({ form, response }: { form: Form; response: Response }) {
for (const [uuid, { value }] of Object.entries(response)) {
responseValues[uuid] = value;
}
if (logic) {
// Leave the logs for easy debugging of routing form logic test.
console.log("Checking logic with response", logic, responseValues);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = jsonLogic.apply(logic as any, responseValues);
} else {
@ -232,8 +252,8 @@ function processRoute({ form, response }: { form: Form; response: Response }) {
return decidedAction;
}
export default function RoutingLink({ form }: { form: Form }) {
return <RoutingForm form={form} />;
export default function RoutingLink({ form, profile }: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm form={form} profile={profile} />;
}
RoutingLink.isThemeSupported = true;
@ -259,6 +279,15 @@ export const getServerSideProps = async function getServerSideProps(
where: {
id: formId,
},
include: {
user: {
select: {
theme: true,
brandColor: true,
darkBrandColor: true,
},
},
},
});
if (!form || form.disabled) {
@ -269,6 +298,11 @@ export const getServerSideProps = async function getServerSideProps(
return {
props: {
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
},
form: getSerializableForm(form),
},
};

View File

@ -36,6 +36,12 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
- Hot reload doesn't work with CSS files in the way we use vite.
## Steps to make a page compatiable with Embed
- Define `main` class on the element that has the entire content of the page with no auto margins
- Adding `main` class allows iframe height to adjust according to it, making sure that the content within `main` is visible without scroll as long as device dimensions permits it.
- It also becomes the area beyond which if user clicks, modal-box would close.
## Known Bugs and Upcoming Improvements
- Unsupported Browsers and versions. Documenting them and gracefully handling that.

View File

@ -10,9 +10,9 @@
const url = new URL(document.URL);
// Only run the example specified by only=, avoids distraction and faster to test.
const only = url.searchParams.get("only");
const namespace = only ? only.replace("ns:",""): null
if (namespace) {
location.hash="#cal-booking-place-" + namespace + "-iframe"
const elementIdentifier = only ? only.replace("ns:",""): null
if (elementIdentifier) {
location.hash="#cal-booking-place-" + elementIdentifier + "-iframe"
}
})()
</script>
@ -89,6 +89,8 @@
<button data-cal-namespace="popupTeamLinkDarkTheme" data-cal-config='{"theme":"dark"}' data-cal-link="team/seeded-team/collective-seeded-team-event">Book with Test Team[Dark Theme]</button>
<button data-cal-namespace="popupTeamLinksList" data-cal-link="team/seeded-team/">See Team Links [Auto Theme]</button>
<button data-cal-namespace="popupPaidEvent" data-cal-link="pro/paid">Book Paid Event [Auto Theme]</button>
<button data-cal-namespace="routingFormAuto" data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">Book through Routing Form [Auto Theme]</button>
<button data-cal-namespace="routingFormDark" data-cal-config='{"theme":"dark"}' data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">Book through Routing Form [Dark Theme]</button>
<div>
<h2>Embed for Pages behind authentication</h2>
<button data-cal-namespace="upcomingBookings" data-cal-config='{"theme":"dark"}' data-cal-link="bookings/upcoming">Show Upcoming Bookings</button>
@ -189,6 +191,22 @@
</div>
</div>
</div>
<div class="debug" id="cal-booking-place-inline-routing-form">
<h2>Inline Routing Form</h2>
<div>
<i><a href="?only=inline-routing-form">Test in Zen Mode</a></i>
</div>
<i class="last-action">
<i>You would see last Booking page action in my place</i>
</i>
<div style="display:flex;align-items: center;">
<h2 style="width: 30%">
On the right side you can book a team meeting =>
</h2>
<div style="width: 70%" class="place">
</div>
</div>
</div>
<script>
const callback = function (e) {
@ -380,6 +398,28 @@
calLink: "free",
});
}
if (!only || only === "inline-routing-form") {
Cal('init', 'inline-routing-form', {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns['inline-routing-form'](
[
"inline",
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/9a7e8801-2f34-45ae-aa79-51859766a860",
config: {
iframeAttrs: {
id: "cal-booking-place-inline-routing-form-iframe"
},
}
},
],
);
}
Cal('init', 'popupDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
@ -416,11 +456,23 @@
debug: 1,
origin: "http://localhost:3000",
});
if (!only || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro"
Cal('init', 'routingFormAuto', {
debug: 1,
origin: "http://localhost:3000",
})
}
Cal('init', 'routingFormDark', {
debug: 1,
origin: "http://localhost:3000",
})
if (!only || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro"
})
}
</script>
<script></script>
</body>

View File

@ -71,6 +71,7 @@ export type ExpectedUrlDetails = {
};
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace PlaywrightTest {
//FIXME: how to restrict it to Frame only
interface Matchers<R> {
@ -78,7 +79,7 @@ declare global {
calNamespace: string,
getActionFiredDetails: Function,
expectedUrlDetails?: ExpectedUrlDetails
): R;
): Promise<R>;
}
}
}

View File

@ -20,7 +20,7 @@ test("should open embed iframe on click - Configured with light theme", async ({
embedIframe = await getEmbedIframe({ page, pathname: "/free" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/free",
});
expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
@ -39,3 +39,28 @@ todo("Floating Button Test with Dark Theme");
todo("Floating Button Test with Light Theme");
todo("Add snapshot test for embed iframe");
test("should open Routing Forms embed on click", async ({
page,
addEmbedListeners,
getActionFiredDetails,
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
const calNamespace = "routingFormAuto";
await addEmbedListeners(calNamespace);
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({ page, pathname: "/forms/948ae412-d995-4865-875a-48302588de03" });
expect(embedIframe).toBeFalsy();
await page.click(
`[data-cal-namespace=${calNamespace}][data-cal-link="forms/948ae412-d995-4865-875a-48302588de03"]`
);
embedIframe = await getEmbedIframe({ page, pathname: "/forms/948ae412-d995-4865-875a-48302588de03" });
if (!embedIframe) {
throw new Error("Routing Form embed iframe not found");
}
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
});
await expect(embedIframe.locator("text=Seeded Form - Pro")).toBeVisible();
});

View File

@ -16,6 +16,8 @@ declare global {
CalComPageStatus: string;
CalComPlan: string;
isEmbed: () => boolean;
getEmbedNamespace: () => string | null;
getEmbedTheme: () => "dark" | "light" | null;
}
}
@ -197,8 +199,7 @@ function getNamespace() {
return embedStore.namespace;
}
if (isBrowser) {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
const namespace = window.getEmbedNamespace();
embedStore.namespace = namespace;
return namespace;
}
@ -219,7 +220,7 @@ export const useIsEmbed = () => {
// We can't simply return isEmbed() from this method.
// isEmbed() returns different values on server and browser, which messes up the hydration.
// TODO: We can avoid using document.URL and instead use Router.
const [_isEmbed, setIsEmbed] = useState<boolean | null>(null);
const [isEmbed, setIsEmbed] = useState<boolean | null>(null);
useEffect(() => {
const namespace = getNamespace();
const _isValidNamespace = isValidNamespace(namespace);
@ -230,7 +231,7 @@ export const useIsEmbed = () => {
}
setIsEmbed(window.isEmbed());
}, []);
return _isEmbed;
return isEmbed;
};
export const useEmbedType = () => {
@ -369,7 +370,7 @@ function keepParentInformedAboutDimensionChanges() {
if (isBrowser) {
const url = new URL(document.URL);
embedStore.theme = (url.searchParams.get("theme") || "auto") as UiConfig["theme"];
embedStore.theme = (window.getEmbedTheme() || "auto") as UiConfig["theme"];
if (url.searchParams.get("prerender") !== "true" && window.isEmbed()) {
log("Initializing embed-iframe");
// HACK

View File

@ -1,11 +1,106 @@
import { Prisma } from "@prisma/client";
import fs from "fs";
import path from "path";
import { uuid } from "short-uuid";
import prisma from ".";
require("dotenv").config({ path: "../../.env.appStore" });
async function seedAppData() {
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: "948ae412-d995-4865-875a-48302588de03",
},
});
if (form) {
console.log(`Skipping Routing Form - Form Seed, "Seeded Form - Pro" already exists`);
return;
}
await prisma.app_RoutingForms_Form.create({
data: {
id: "948ae412-d995-4865-875a-48302588de03",
routes: [
{
id: "8a898988-89ab-4cde-b012-31823f708642",
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
queryValue: {
id: "8a898988-89ab-4cde-b012-31823f708642",
type: "group",
children1: {
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["event-routing"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
action: { type: "customPageMessage", value: "Custom Page Result" },
queryValue: {
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
type: "group",
children1: {
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["custom-page"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: "https://google.com" },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
children1: {
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["external-redirect"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: [
{ id: "c4296635-9f12-47b1-8153-c3a854649182", type: "text", label: "Test field", required: true },
],
user: {
connect: {
username: "pro",
},
},
name: "Seeded Form - Pro",
},
});
}
async function createApp(
/** The App identifier in the DB also used for public page in `/apps/[slug]` */
slug: Prisma.AppCreateInput["slug"],
@ -28,7 +123,7 @@ async function createApp(
console.log(`📲 Upserted app: '${slug}'`);
}
async function main() {
export default async function main() {
// Calendar apps
await createApp("apple-calendar", "applecalendar", ["calendar"], "apple_calendar");
await createApp("caldav-calendar", "caldavcalendar", ["calendar"], "caldav_calendar");
@ -144,13 +239,6 @@ async function main() {
const generatedApp = generatedApps[i];
await createApp(generatedApp.slug, generatedApp.dirName, generatedApp.categories, generatedApp.type);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
await seedAppData();
}

View File

@ -6,7 +6,7 @@ import { hashPassword } from "@calcom/lib/auth";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import prisma from ".";
import "./seed-app-store";
import mainAppStore from "./seed-app-store";
require("dotenv").config({ path: "../../.env" });
async function createUserAndEventType(opts: {
@ -564,6 +564,7 @@ async function main() {
}
main()
.then(() => mainAppStore())
.catch((e) => {
console.error(e);
process.exit(1);