feat: Prerender booking link and reuse with headless router (#20720)

* mvp done

* wip

* fix ts errors and other code improvements

* fix ts errors

* ensure mobile layout support

* Make skeleton responsive on screen resize

* refactor

* Add test for EmbedElement

* make skeleton closer to pixel perfect

* Address PR feedback

* Router-preloading
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings

* wip\

* wip

* fix mrge.io feedback

* wip

* Add README and lifecycle

* Add README and lifecycle

* Update routing form-seed and some other fixes

* remove linkFailed fix from the branch

* self-review

* self-review-2

* self-review-3

* Handle soft connect\

* Update README and fix a bug with query parmas

* Add one more case in routing-html playground

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
This commit is contained in:
Hariom Balhara
2025-05-08 19:59:26 +01:00
committed by GitHub
co-authored by Benny Joo amrit
parent 3ee6a7ecde
commit 6e9ec113fe
26 changed files with 3048 additions and 211 deletions
+43
View File
@@ -0,0 +1,43 @@
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
export default defaultHandler({
OPTIONS: Promise.resolve({
default: defaultResponder(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, cache-control, pragma");
res.status(204).end();
}),
}),
POST: Promise.resolve({
default: defaultResponder(async (req, res) => {
// getRoutedUrl has more detailed schema validation, we do a basic one here.
const params = req.body;
res.setHeader("Access-Control-Allow-Origin", "*");
const routedUrlData = await getRoutedUrl({ req, query: { ...params } });
if (routedUrlData?.notFound) {
return res.status(404).json({ status: "error", data: { message: "Form not found" } });
}
if (routedUrlData?.redirect?.destination) {
return res
.status(200)
.json({ status: "success", data: { redirect: routedUrlData.redirect.destination } });
}
if (routedUrlData?.props?.errorMessage) {
return res.status(400).json({ status: "error", data: { message: routedUrlData.props.errorMessage } });
}
if (routedUrlData?.props?.message) {
return res.status(200).json({ status: "success", data: { message: routedUrlData.props.message } });
}
return res
.status(500)
.json({ status: "error", data: { message: "Neither Route nor custom message found." } });
}),
}),
});
+2 -2
View File
@@ -8,7 +8,7 @@ import PageWrapper from "@components/PageWrapper";
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
export default function Router({ form, message, errorMessage }: inferSSRProps<typeof getServerSideProps>) {
return (
<>
<Head>
@@ -17,7 +17,7 @@ export default function Router({ form, message }: inferSSRProps<typeof getServer
<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="text-default bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div>{message}</div>
<div>{message || errorMessage}</div>
</div>
</div>
</div>
+202
View File
@@ -0,0 +1,202 @@
# Cal.com Embed Lifecycle Events
This document details the lifecycle events and states of Cal.com embeds, showing the interaction flow between the parent page and the iframe.
## Core Lifecycle Sequence
```mermaid
sequenceDiagram
participant Parent as Parent (User Page)
participant Embed as Embed (iframe)
participant Store as EmbedStore(iframe)
participant UI as UI Components(iframe)
Note over Parent: embed.js loads
alt Inline Embed
Parent->>Parent: Create cal-element
Parent->>Parent: Create iframe (visibility: hidden)
Parent->>Parent: Show loader
else Modal Embed
Note over Parent: No action unless prerender
end
alt Prerender Flow
Note over Parent: Set prerender=true in URL
Parent->>Store: Set prerenderState="inProgress"
Note over Store: Limited events allowed
Parent->>Parent: Create hidden iframe
Parent->>Parent: load booker(no slots)
Note over Parent: Wait for connect() call
end
alt Modal CTA clicked
Parent->>Parent: Create cal-modal-box
Parent->>Parent: Create iframe (visibility: hidden)
Parent->>Parent: Show loader
end
Note over Embed: background: transparent(stays transparent)
Note over Embed: body tag set to visibility: hidden waiting to be shown
Note over Embed: iframe webpage starts rendering
Note over Store: Initialize EmbedStore state
Store->>Store: Set NOT_INITIALIZED state
Store->>Store: Initialize UI config & theme
Note over Parent: Process URL params for prefill
Parent->>Store: Set prefill data from URL
Note over Store: Auto-populate form fields
Embed->>Parent: __iframeReady event
Note over Parent,Embed: Embed ready to receive messages
Note over Store: Set state to INITIALIZED
Store->>UI: Apply theme configuration
Note over UI: DEPRECATED: styles prop
Note over UI: Use cssVarsPerTheme instead
Store->>UI: Apply cssVarsPerTheme
Embed->>Parent: __dimensionChanged event
Note over Parent: Calculate and adjust iframe dimensions
Note over Store: Update parentInformedAboutContentHeight
alt isBookerPage
Note over Embed: Wait for booker ready state
end
Embed->>Parent: linkReady event
Note over Parent: Changes loading state to done
Note over Parent: Removes loader
Note over Parent: Sets iframe visibility to visible
Parent->>Embed: parentKnowsIframeReady event
Note over Embed: Makes body visible
Note over Store: Update UI configuration
alt Prerendering Active
Note over Store: Set prerenderState to completed
Parent->>Store: connect() with new config
Store->>Store: Reset parentInformedAboutContentHeight
Store->>Parent: Update iframe with new params
Note over Store: Remove prerender params
end
loop Dimension Monitoring
Embed->>Embed: Monitor content size changes
alt Dimensions Changed
Embed->>Parent: __dimensionChanged event
Parent->>Parent: Adjust iframe size to avoid scrollbar
end
end
alt Route Changes
UI->>Store: Update UI state
Store->>Parent: __routeChanged event
Parent->>Parent: Handle navigation
Note over Store: Preserve prefill data
end
```
## Detailed State Management
### EmbedStore States
- `NOT_INITIALIZED`: Initial state when iframe is created
- `INITIALIZED`: After __iframeReady event is processed
- `prerenderState`: Can be null | "inProgress" | "completed"
### Visibility States
1. Initial Creation:
- iframe.style.visibility = "hidden"
- body.style.visibility = "hidden"
2. After __iframeReady:
- iframe becomes visible (unless prerendering)
3. After parentKnowsIframeReady:
- body becomes visible
- Background remains transparent
## Event Details
1. **Initial Load**
- embed.js loads in parent page
- For inline embeds: Creates elements immediately
- For modal embeds: Waits for CTA click (unless prerendering)
2. **iframe Creation**
- iframe is created with `visibility: hidden`
- Loader is shown (default or skeleton)
- EmbedStore initialized
3. **__iframeReady Event**
- Fired by: Iframe
- Indicates: Embed is ready to receive messages
- Actions:
- Sets iframeReady flag to true
- Makes iframe visible (unless prerendering)
- Processes queued iframe commands
4. **__dimensionChanged Event**
- Fired by: Iframe
- Purpose: Maintain proper iframe sizing
- Triggers:
- On initial load
- When content size changes
- After window load completes
5. **linkReady Event**
- Fired by: Iframe
- Indicates: iframe is fully ready for use
- Requirements:
- parentInformedAboutContentHeight must be true
- For booker pages: booker must be in ready state
- Actions:
- Parent removes loader
- Parent makes iframe visible
6. **parentKnowsIframeReady Event**
- Fired by: Parent
- Indicates: Parent acknowledges iframe readiness
- Actions:
- Makes body visible
- For prerendering: marks prerenderState as "completed"
## Prerendering Flow
The prerendering flow follows a special path:
1. Initial State:
- prerenderState: null
2. During Prerender:
- prerenderState: "inProgress"
- Limited events allowed (only __iframeReady, __dimensionChanged)
- iframe and body remain hidden
3. After Connect:
- prerenderState: "completed"
- Full event flow enabled
- Visibility states updated
## Command Queue System
The embed system implements a command queue to handle instructions before the iframe is ready:
1. Commands are queued if iframe isn't ready:
```typescript
if (!this.iframeReady) {
this.iframeDoQueue.push(doInIframeArg);
return;
}
```
2. Queue is processed after __iframeReady event:
- All queued commands are executed in order
- New commands are executed immediately
## Error Handling
Page Load Errors:
- System monitors CalComPageStatus
- On non-200 status: fires linkFailed event
- Includes error code and URL information
+157
View File
@@ -37,3 +37,160 @@ Status:
- [x] [Partially supported] team.event.booking.form - Shows skeleton but of the slots page
- [ ] user.profile
- [ ] team.profile
## How Routing Prerendering works
- Use API to prerender a booking link for "modal"
- When CTA is clicked by user, we check if there is a "prerendered"/"being prerendered" modal for this namespace.
- If yes, we open up the modal showing the skeleton loader and send the POST request to /api/router endpoint
- When we get the response from the endpoint, we pass on all the query params to the already rendered/being rendered iframe and embed-iframe updates the URL of the iframe to have the new query params through history.replaceState(i.e. without reloading the page)
## Prerendering vs Preloading
- Preloading loads the calLink in iframe with the sole purpose of preloading the static assets, so that when the embed actually opens, it uses the static assets from browser cache.
- Prerendering means continuing over the preloaded iframe, so that the user books on the prerendered iframe only. So, it is much more complex than preloading and gives much more benefits in terms of performance.
Note: API wise `prerender` delegates its task to `preload` API which then identifies whether to preload or prerender.
## Modalbox re-opening performance optimization
- ModalBox supports reusing the same cal-modal-box element and thus same iframe and thus providing a lightning fast experience when the same modal is opened multiple times [This feature is currently disabled in code because of stale booking page UI issues]
## Embed Core Architecture and Features
### Architecture Overview
#### Initialization and Bootstrap Process
The embed system initializes through a multi-step process:
1. The embed script is loaded on the parent page
2. It creates a global `Cal` object that acts as the entry point
3. The system initializes necessary custom elements (`cal-modal-box`, `cal-floating-button`, `cal-inline`)
4. A namespace-based action manager is created for event handling
#### Parent-Iframe Communication System
Communication between the parent page and the embedded iframe uses a message-based system:
```typescript
// Parent to Iframe communication example
interface InterfaceWithParent {
ui: (config: UiConfig) => void;
connect: (config: PrefillAndIframeAttrsConfig) => void;
}
// Event data structure
type EventData<T> = {
type: string;
namespace: string;
fullType: string;
data: EventDataMap[T];
};
```
The system uses namespaced events to ensure multiple embeds on the same page don't interfere with each other.
#### Instruction Queue System
Commands are queued before the iframe is ready:
```typescript
type Instruction = SingleInstruction | SingleInstruction[];
type InstructionQueue = Instruction[];
// Commands are queued if iframe isn't ready
if (!this.iframeReady) {
this.iframeDoQueue.push(doInIframeArg);
return;
}
```
### Embedding Methods
#### Inline Embedding
Embeds the calendar directly within the page flow:
```typescript
Cal.inline({
elementOrSelector: "#my-cal-inline",
calLink: "organization/event-type"
});
```
#### Modal Embedding
Creates a modal dialog with the calendar:
```typescript
Cal.modal({
calLink: "organization/event-type",
config: {
// Optional configuration
}
});
```
#### FloatingButton Embedding
Adds a floating action button that opens the calendar in a modal. It uses modal embedding under the hood.
```typescript
Cal.floatingButton({
calLink: "organization/event-type",
buttonText: "Book meeting",
buttonPosition: "bottom-right"
});
```
### Configuration and Customization
#### Prefill System
Allows pre-filling form fields:
```typescript
Cal.inline({
calLink: "organization/event-type",
config: {
name: "John Doe",
email: "john@example.com",
notes: "Initial discussion"
}
});
```
#### Query Parameter Handling
The system allows automatically forwarding query params to the iframe, by setting. This code must be present right after the embed snippet is added to the page.
```js
Cal.config = Cal.config || {};
Cal.config.forwardQueryParams=true
```
### Advanced Features
#### Routing Prerendering System
The prerendering system optimizes the initial load:
```typescript
Cal.prerender({
calLink: "organization/event-type",
type: "modal"
});
```
Key aspects:
- Creates a hidden iframe
- Loads the booking page but doesn't send the slots availability request
- Tries to reuse whenever it makes sense and do a fresh load otherwise
Iframe Reuse and Reload Conditions. There could be three situations:
1. Reuse
2. Reuse the iframe but refetch the slots
3. Do a fresh load in iframe
- **Reuse**:
- Modal opens when
- Modal is not in a failed state
- config, params are same as the last time
- No threshold violations
- **Reuse the iframe but refetch the slots**:
- Only embed `config` changes (handled via "connect" flow)
- Query query params changes (handled via "connect" flow)
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
- **Fresh Reload Conditions**:
- Different path being loaded(i.e. /pro vs /free)
- Modal is in a failed state
- Time since last render exceeds EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
+2 -1
View File
@@ -10,7 +10,8 @@ You can also see various example usages [here](https://github.com/calcom/cal.com
## Development
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100`
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100/embed/`
We have another html page to specifically test prerendering when using headless router in Demo `http://localhost:3100/embed/routing-playground.html`
```bash
yarn dev
+24
View File
@@ -669,6 +669,30 @@ if (only === "all" || only === "ns:skeletonDemoElementClick") {
});
}
if (only === "all" || only === "ns:routingFormPrerender") {
Cal("init", "routingFormPrerender", {
debug: true,
origin,
});
Cal.ns.routingFormPrerender("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:routingFormWithoutPrerender") {
Cal("init", "routingFormWithoutPrerender", {
debug: true,
origin,
});
Cal.ns.routingFormWithoutPrerender("on", {
action: "*",
callback,
});
}
// Keep it at the bottom as it works on the API defined above for various cases
(function ensureScrolledToCorrectIframe() {
// Reset the hash so that we can scroll to correct iframe
@@ -0,0 +1,176 @@
<html>
<head>
<title>Embed Playground - Routing Form</title>
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
const url = new URL(document.URL);
window.only = url.searchParams.get("only")
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
window.calLink = url.searchParams.get("calLink");
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');
}
(function addParamsAutomatically() {
const newSearchParams = new URLSearchParams(location.search);
let paramsChanged = false;
if (!newSearchParams.has("only")) {
newSearchParams.set("only", "ns:routingFormPrerender");
paramsChanged = true;
}
if (!newSearchParams.has("calOrigin")) {
newSearchParams.set("calOrigin", "http://acme.cal.local:3000");
paramsChanged = true;
}
if (!newSearchParams.has("cal.embed.logging")) {
newSearchParams.set("cal.embed.logging", "1");
paramsChanged = true;
}
if (paramsChanged) {
location.href = "?" + newSearchParams.toString();
}
})();
</script>
<script>
function embedSnippet() {
(function (C, A, L) {
let p = function (a, ar) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api = function () {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
if (typeof namespace === "string") {
// Make sure that even after re-execution of the snippet, the namespace is not overridden
cal.ns[namespace] = cal.ns[namespace] || api;
p(cal.ns[namespace], ar);
p(cal, ['initNamespace', namespace])
} else p(cal, ar);
return;
}
p(cal, ar);
};
})(window, window.calOrigin + "/embed/embed.js", "init");
}
embedSnippet();
</script>
<style>
body {
background: linear-gradient(90deg,
rgba(120, 116, 186, 1) 0%,
rgba(221, 221, 255, 1) 41%,
rgba(148, 232, 249, 1) 100%);
}
.inline-embed-container {
/* border: 1px solid black; */
margin-bottom: 5px;
border-bottom: 1px solid;
}
.loader {
color: green;
}
* {
--cal-brand-color: gray;
}
</style>
</head>
<body>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div id="cal-booking-place-routingFormPrerender">
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Demo</a>
<p>1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)</p>
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal</p>
<p>3. Changing the form response and clicking submit, will re-submit the response and reopen the modal with the updated data(update slots call, updated prefill fields)</p>
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as threshold of 1 min since last time the modal was shown is met). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
<form id="cal-booking-place-routingFormPrerender-form">
<input type="text" name="name" placeholder="John Doe" />
<input type="email" name="email" placeholder="john@example.com" />
<select name="skills" placeholder="JavaScript, Node.js">
<option value="JavaScript">JavaScript</option>
<option value="Sales">Sales</option>
</select>
</form>
<button id="cal-booking-place-routingFormPrerender-submit" data-cal-namespace="routingFormPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
<script>
document.getElementById("cal-booking-place-routingFormPrerender-form").addEventListener("input", function prerender(e) {
if (prerender.initiated) return;
prerender.initiated = true;
console.log("Playground: prerender initiated");
Cal.ns.routingFormPrerender("prerender", {
calLink: "team/insights-team/team-javascript",
type: "modal",
pageType: "user.event.booking.slots",
});
});
requestAnimationFrame(function updateSubmitButtonLink() {
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
const form = document.getElementById("cal-booking-place-routingFormPrerender-form");
const name = form.querySelector("input[name='name']").value;
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
<div id="cal-booking-place-routingFormWithoutPrerender">
<a href="?only=ns:routingFormWithoutPrerender">Routing Form Without Prerender Demo</a>
<form id="cal-booking-place-routingFormWithoutPrerender-form">
<input type="text" name="name" placeholder="John Doe" />
<input type="email" name="email" placeholder="john@example.com" />
<select name="skills" placeholder="JavaScript, Node.js">
<option value="JavaScript">JavaScript</option>
<option value="Sales">Sales</option>
</select>
</form>
<button id="cal-booking-place-routingFormWithoutPrerender-submit" data-cal-namespace="routingFormWithoutPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
<script>
requestAnimationFrame(function updateSubmitButtonLink() {
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
const form = document.getElementById("cal-booking-place-routingFormWithoutPrerender-form");
const name = form.querySelector("input[name='name']").value;
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
</div>
<script type="module" src="./playground.ts"></script>
</script>
</body>
</html>
@@ -165,12 +165,24 @@ export class EmbedElement extends HTMLElement {
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
}
public isSkeletonLoaderVisible() {
const skeletonEl = this.getSkeletonElement();
// Comparing with "none" which is set by toggleLoader when skeleton is hidden
return skeletonEl.style.display !== "none";
}
public resizeHandler() {
const newLayout = getTrueLayout({ layout: this.layout ?? null });
if (newLayout === this.layout) {
return;
}
this.layout = newLayout;
// We can't accidentaly show skeleton if it isn't showing
if (!this.isSkeletonLoaderVisible()) {
return;
}
const { skeletonContent, skeletonContainerStyle, skeletonStyle } = this.getSkeletonData({
layout: this.getLayout(),
pageType: this.getPageType() ?? null,
@@ -22,7 +22,10 @@ export class Inline extends EmbedElement {
this.toggleLoader(false);
slotEl.style.visibility = "hidden";
errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode);
const errorString = getErrorString({
errorCode: this.dataset.errorCode,
errorMessage: this.dataset.message,
});
errorEl.innerText = errorString;
}
}
@@ -12,6 +12,9 @@ export class ModalBox extends EmbedElement {
return ["state"];
}
/**
* Show the modal box, regardless of anything else - Kind of like forced open
*/
show(show: boolean) {
this.assertHasShadowRoot();
// We can't make it display none as that takes iframe width and height calculations to 0
@@ -21,7 +24,14 @@ export class ModalBox extends EmbedElement {
}
}
/**
* Open the modal box if it makes sense to do so.
*/
open() {
if (this.getAttribute("state") === "prerendering") {
// You can't show a modal thats prerendering or prerendered
return;
}
this.show(true);
const event = new Event("open");
this.dispatchEvent(event);
@@ -29,30 +39,71 @@ export class ModalBox extends EmbedElement {
private isLoaderRunning() {
const state = this.getAttribute("state");
return !state || state === "loading" || state === "reopening";
return !state || state === "loading";
}
/**
* Close the modal box - It is like a forced close
*/
private explicitClose() {
this.show(false);
const event = new Event("close");
this.dispatchEvent(event);
}
isShowingMessage() {
const currentState = this.getAttribute("state");
return currentState === "has-message" || currentState === "failed";
}
/**
* Close the modal box if it makes sense to do so.
*/
close() {
if (this.isLoaderRunning()) {
// We want to avoid accidentally closing the modal through Esc, Click outside, etc.
if (this.isLoaderRunning() || this.isShowingMessage()) {
return;
}
this.explicitClose();
}
hideIframe() {
/**
* Takes the iframe out of layout and hides it
*/
collapseIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.display = "none";
}
}
/**
* Put the iframe back in layout and make it visible
*/
uncollapseIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
// Resets the display to its default value
iframe.style.display = "";
}
}
/**
* Make the iframe invisible but stays in layout
*/
makeIframeInvisible() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.visibility = "hidden";
}
}
showIframe() {
/**
* Make the iframe visible, doesn't affect layout
*
* Iframe on creation is invisible
*/
makeIframeVisible() {
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
@@ -60,40 +111,108 @@ export class ModalBox extends EmbedElement {
}
}
getErrorElement(): HTMLElement {
getMessageElement(): HTMLElement {
this.assertHasShadowRoot();
const element = this.shadowRoot.querySelector<HTMLElement>("#error");
const element = this.shadowRoot.querySelector<HTMLElement>("#message");
if (!element) {
throw new Error("No error element");
throw new Error("No message element");
}
return element;
}
getMessageContainerElement(): HTMLElement {
this.assertHasShadowRoot();
const element = this.shadowRoot.querySelector<HTMLElement>("#message-container");
if (!element) {
throw new Error("No message container element");
}
return element;
}
toggleMessageElement(show: boolean) {
const errorContainerElement = this.getMessageContainerElement();
if (show) {
errorContainerElement.style.display = "";
} else {
errorContainerElement.style.display = "none";
}
}
ensureIframeFullyVisible() {
// Iframe when added is invisible, so we ensure that it is visible
this.makeIframeVisible();
// Iframe is collapsed(doesn't take any space) when error is to be shown, so we ensure that it is uncollapsed
this.uncollapseIframe();
}
/**
* Called when the state is "loaded"
*/
onStateLoaded() {
// Hide Loader
this.toggleLoader(false);
// Message is shown either in "failed" or "has-message" state, so we hide it here
this.toggleMessageElement(false);
// Open Modal
this.open();
// Ensure Iframe is fully visible
this.ensureIframeFullyVisible();
}
/**
* Called when the state is "failed" or "has-message"
*/
onStateFailedOrMessage() {
this.toggleLoader(false);
this.toggleMessageElement(true);
this.collapseIframe();
const message = this.dataset.message;
const errorMessage = this.dataset.errorCode
? getErrorString({
errorCode: this.dataset.errorCode,
errorMessage: message,
})
: null;
const messageToShow = errorMessage || message;
if (messageToShow) {
this.getMessageElement().innerText = messageToShow;
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name !== "state") {
return;
}
if (newValue === "loading") {
this.open();
this.hideIframe();
this.toggleLoader(true);
} else if (newValue == "loaded" || newValue === "reopening") {
this.open();
this.showIframe();
this.toggleLoader(false);
// Ensure iframe takes its available space as it could be possible that we are moving from "failed" state to "loading" state or some other case.
this.uncollapseIframe();
this.toggleMessageElement(false);
// Keep iframe invisible as it will be made visible when the state changes to "loaded"
this.makeIframeInvisible();
} else if (newValue == "loaded") {
this.onStateLoaded();
} else if (newValue == "closed") {
this.explicitClose();
} else if (newValue === "failed") {
this.getLoaderElement().style.display = "none";
this.getSkeletonElement().style.display = "none";
this.getErrorElement().style.display = "inline-block";
const errorString = getErrorString(this.dataset.errorCode);
this.getErrorElement().innerText = errorString;
} else if (newValue === "failed" || newValue === "has-message") {
this.onStateFailedOrMessage();
} else if (newValue === "prerendering") {
// We do a close here because we don't want the loaders to show up when the modal is prerendering
// As per HTML, both skeleton/loader are configured to be shown by default, so we need to hide them and infact we don't want to show up anything unexpected so we completely hide the customElement itself
this.explicitClose();
} else if (newValue === "reopened") {
// Show in whatever state it is
this.open();
}
}
@@ -27,6 +27,11 @@ function getStyle() {
overflow: auto;
}
.message-container {
min-height: 200px;
width: 600px;
}
.header {
position: relative;
float:right;
@@ -58,12 +63,17 @@ const html = ({
layout,
pageType,
});
// Keep message-container outside modal-box as that restricts the content to be shown through its overflow:auto unnecessarily
return `
${getStyle()}
<div class="my-backdrop">
<div class="header">
<button type="button" class="close" aria-label="Close">&times;</button>
</div>
<div id="message-container" style="left: 50%; top: 50%; transform: translate(-50%, -50%);" class="message-container flex items-center p-24 justify-center dark:bg-muted rounded-md border-subtle border bg-default text-default absolute z-highest">
<div id="message"></div>
</div>
<div class="modal-box">
<div class="body" id="skeleton-container" style="${skeletonContainerStyle}">
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
@@ -74,10 +84,10 @@ ${getStyle()}
<div id="skeleton" style="${skeletonStyle}" class="absolute z-highest">
${skeletonContent}
</div>
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
<slot></slot>
</div>
</div>
</div>`;
};
@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { embedStore, getEmbedBookerState } from "../embed-iframe";
// Test helper functions
type fakeCurrentDocumentUrlParams = {
origin?: string;
path?: string;
params?: Record<string, string>;
};
function fakeCurrentDocumentUrl({
origin = "https://example.com",
path = "",
params = {},
}: fakeCurrentDocumentUrlParams = {}) {
const url = new URL(path, origin);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return mockDocumentUrl(url);
}
function mockDocumentUrl(url: URL | string) {
return vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
}
function createSearchParams(params: Record<string, string>) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.set(key, value);
});
return searchParams;
}
describe("embedStore.router.ensureQueryParamsInUrl", () => {
// Mock window.history and URL
const originalHistory = window.history;
const originalURL = window.URL;
function nextTick() {
vi.advanceTimersByTime(100);
}
beforeEach(() => {
vi.useFakeTimers();
// Mock requestAnimationFrame and cancelAnimationFrame
window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
console.log("mockRequestAnimationFrame called");
const timeoutId = setTimeout(() => {
callback(performance.now());
}, 100) as unknown as number;
return timeoutId;
});
// Mock history.replaceState
window.history.replaceState = (...args) => {
const url = args[2];
if (!url) {
throw new Error("url is not provided");
}
vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
};
});
afterEach(() => {
vi.useRealTimers();
// Cleanup
window.history = originalHistory;
window.URL = originalURL;
vi.resetAllMocks();
});
it("should add missing parameters to URL", async () => {
// Setup
fakeCurrentDocumentUrl();
// Execute
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {
theme: "dark",
layout: "month",
},
toRemoveParams: [],
});
// Assert
expect(document.URL).toContain("theme=dark");
expect(document.URL).toContain("layout=month");
// Cleanup
stopEnsuringQueryParamsInUrl();
});
it("should ensure that no existing value of param exists as is", () => {
fakeCurrentDocumentUrl({ params: { guest: "initial" } });
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: { guest: ["ax.com", "bx.com"] },
toRemoveParams: [],
});
expect(document.URL).toContain("guest=ax.com");
expect(document.URL).toContain("guest=bx.com");
expect(document.URL).not.toContain("guest=initial");
stopEnsuringQueryParamsInUrl();
});
it("should remove specified parameters from URL", async () => {
// Setup
fakeCurrentDocumentUrl({ params: { remove: "true", keep: "yes" } });
// Execute
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {},
toRemoveParams: ["remove"],
});
// Assert
expect(document.URL).not.toContain("remove=true");
expect(document.URL).toContain("keep=yes");
// Cleanup
stopEnsuringQueryParamsInUrl();
});
it("should handle empty parameters", async () => {
fakeCurrentDocumentUrl();
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {},
toRemoveParams: [],
});
nextTick();
stopEnsuringQueryParamsInUrl();
});
it("should restore parameters if they are changed before cleanup, otherwise not", async () => {
fakeCurrentDocumentUrl();
const initialTheme = "dark";
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: { theme: initialTheme },
toRemoveParams: [],
});
// First interval - should add the parameter
expect(document.URL).toContain(`theme=${initialTheme}`);
const changedThemeByReact = "light";
// Simulate React code changing the URL
fakeCurrentDocumentUrl({ params: { theme: changedThemeByReact } });
// Next interval - should restore our parameter
nextTick();
expect(document.URL).toContain(`theme=${initialTheme}`);
// After cleanup, changes should not be restored
stopEnsuringQueryParamsInUrl();
fakeCurrentDocumentUrl({ params: { theme: "light" } });
nextTick();
expect(document.URL).toContain("theme=light");
expect(document.URL).not.toContain(`theme=${initialTheme}`);
});
});
describe("getEmbedBookerState", () => {
it("should return 'initializing' when bookerState is 'loading'", () => {
const result = getEmbedBookerState({
bookerState: "loading",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("initializing");
});
it("should return 'slotsLoading' when slotsQuery.isLoading is true", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: true,
isPending: false,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("slotsLoading");
});
it("should return 'slotsDone' when slotsQuery.isPending is true but not loading", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: true,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("slotsDone");
});
it("should return 'slotsDone' when slotsQuery.isSuccess is true", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: true,
isError: false,
},
});
expect(result).toBe("slotsDone");
});
it("should return 'slotsLoadingError' when slotsQuery.isError is true", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: false,
isError: true,
},
});
expect(result).toBe("slotsLoadingError");
});
it("should return 'slotsPending' when no other conditions are met", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("slotsPending");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { generateDataAttributes } from "../utils";
import { generateDataAttributes, isSameBookingLink } from "../utils";
describe("generateDataAttributes", () => {
it("should handle PascalCase property names correctly", () => {
@@ -59,3 +59,41 @@ describe("generateDataAttributes", () => {
);
});
});
describe("isSameBookingLink", () => {
it("should return true for same booking link", () => {
expect(
isSameBookingLink({
bookingLinkPath1: "/team/event-booking-url",
bookingLinkPath2: "/team/event-booking-url",
})
).toBe(true);
expect(
isSameBookingLink({
bookingLinkPath1: "/team/team1/event-booking-url",
bookingLinkPath2: "/team/team1/event-booking-url",
})
).toBe(true);
});
it("should return false for different booking links", () => {
expect(
isSameBookingLink({
bookingLinkPath1: "/team/event-booking-url",
bookingLinkPath2: "/team/event-booking-url-2",
})
).toBe(false);
});
it("should return true for same booking links with /team prefix in them", () => {
expect(isSameBookingLink({ bookingLinkPath1: "/team/sales/demo", bookingLinkPath2: "/sales/demo" })).toBe(
true
);
expect(
isSameBookingLink({
bookingLinkPath1: "/team1/event-booking-url",
bookingLinkPath2: "/team/team1/event-booking-url",
})
).toBe(true);
});
});
@@ -0,0 +1,10 @@
// We can't keep it too high because that might cause stale content to be shown
// TODO: We should introduce a slotRefresh mechanism
export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 10 * 1000; // 1 minute
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
throw new Error(
"EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS"
);
}
+284 -76
View File
@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
import type { Message } from "./embed";
@@ -11,9 +10,25 @@ import type {
EmbedNonStylesConfig,
BookerLayouts,
EmbedStyles,
KnownConfig,
EmbedBookerState,
SlotsQuery,
PrefillAndIframeAttrsConfig,
} from "./types";
import { useCompatSearchParams } from "./useCompatSearchParams";
import { isParamValuePresentInUrlSearchParams } from "./utils";
// We don't import it from Booker/types because the types from this module are published to npm and we can't import packages that aren't published
type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
// Prerendering is a hidden process and we shouldn't really track any events from it unless absolutely necessary
const eventsAllowedInPrerendering = [
// so that Postmessage communication starts
"__iframeReady",
// so that iframe height is adjusted according to the content, and iframe is ready to be shown when needed
"__dimensionChanged",
// For other events, we should consider introducing prerender specific events and not reuse existing events
];
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
@@ -22,16 +37,6 @@ const enum EMBED_IFRAME_STATE {
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;
};
} & KnownConfig;
declare global {
interface Window {
CalEmbed: {
@@ -40,32 +45,89 @@ declare global {
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
};
// Marks that Booker has moved to some non-"loading" state
_embedBookerState?: "initializing" | "done";
_embedBookerState?: EmbedBookerState;
}
}
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
export const embedStore = {
connectVersion: 0 as number,
/**
* Tracks whether the prerender has been completed or not.
* NOTE: prerenderState would be "completed" even after the iframe was switched from isPrerendering=true to not Prerendering(which happens after connect)
*/
prerenderState: null as null | "inProgress" | "completed",
// 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;
/**
* When we do the history push, it is possible that
* - React might revert that change depending on in what state React is in while initializing
* - So, we use a declarative approach to ensure that our requirement is continuously met
*/
ensureQueryParamsInUrl({
toBeThereParams,
toRemoveParams,
}: {
toBeThereParams: Record<string, string | string[]>;
toRemoveParams: string[];
}) {
let stopUpdating = false;
function updateIfNeeded() {
if (stopUpdating) {
return { hasChanged: false };
}
const currentUrl = new URL(document.URL);
let hasChanged = false;
// 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);
// Ensuring toBeThereSearchParams
for (const [key, newValue] of Object.entries(toBeThereParams)) {
// It checks that the value must be present and if an array no other item should be there except those in newValue
hasChanged = !isParamValuePresentInUrlSearchParams({
param: key,
value: newValue,
container: currentUrl.searchParams,
});
if (hasChanged) {
setParamInUrl({ key, value: newValue, url: currentUrl });
}
}
removeParamsFromUrl({ keys: toRemoveParams, url: currentUrl });
hasChanged = hasChanged || toRemoveParams.length > 0;
if (hasChanged) {
// Avoid unnecessary history push
window.history.replaceState({}, "", currentUrl.toString());
}
requestAnimationFrame(updateIfNeeded);
return {
hasChanged,
};
}
const { hasChanged } = updateIfNeeded();
return {
stopEnsuringQueryParamsInUrl: () => {
stopUpdating = true;
},
hasChanged,
};
function removeParamsFromUrl({ keys, url }: { keys: string[]; url: URL }) {
for (const key of keys) {
url.searchParams.delete(key);
}
}
function setParamInUrl({ key, value, url }: { key: string; value: string | string[]; url: URL }) {
// Reset and then set the new value, to ensure nothing else remains in value
url.searchParams.delete(key);
const newValueArray = Array.isArray(value) ? value : [value];
newValueArray.forEach((val) => {
url.searchParams.append(key, val);
});
}
},
},
@@ -125,7 +187,7 @@ function log(...args: unknown[]) {
args.unshift("CAL:");
logQueue.push(args);
if (searchParams.get("debug")) {
console.log(...args);
console.log("Child:", ...args);
}
}
}
@@ -199,8 +261,6 @@ 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) {
@@ -309,8 +369,25 @@ function getEmbedType() {
}
}
/**
* It is important to be able to check realtime(instead of storing isLinkReady as a variable) if the link is ready, because there is a possibility that booker might have moved to non-ready state from ready state
*/
function isLinkReady() {
if (!embedStore.parentInformedAboutContentHeight) {
return false;
}
if (isBookerPage()) {
// Let's wait for Booker to be ready before showing the embed
// It means that booker has loaded all its data and is ready to show
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
return isBookerReady();
}
return true;
}
function isBookerReady() {
return window._embedBookerState === "done";
return window._embedBookerState === "slotsDone";
}
function isBookerPage() {
@@ -340,16 +417,33 @@ export const useEmbedType = () => {
return state;
};
function unhideBody() {
function makeBodyVisible() {
if (document.body.style.visibility !== "visible") {
document.body.style.visibility = "visible";
}
// Ensure that it stays visible and not reverted by React
runAsap(() => {
unhideBody();
makeBodyVisible();
});
}
/**
* On an embed page, there are two changes done
* - Body is made invisible
* - Background is set to transparent
*
* This function reverses both of them
*/
function showPageAsNonEmbed() {
makeBodyVisible();
resetTransparentBackground();
function resetTransparentBackground() {
if (document.body.style.background === "transparent") {
document.body.style.background = "";
}
}
}
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
const methods = {
ui: function style(uiConfig: UiConfig) {
@@ -401,43 +495,62 @@ const methods = {
log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
if (!embedStore.parentInformedAboutContentHeight) {
runAsap(tryInformingLinkReady);
return;
}
// Let's wait for Booker to be ready before showing the embed
// It means that booker has loaded all its data and is ready to show
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
if (isBookerPage() && !isBookerReady()) {
if (!isLinkReady()) {
runAsap(tryInformingLinkReady);
return;
}
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
unhideBody();
if (!isPrerendering()) {
sdkActionManager?.fire("linkReady", {});
// Embed background must still remain transparent
makeBodyVisible();
if (isPrerendering()) {
log("prerenderState is 'completed'");
embedStore.prerenderState = "completed";
}
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);
}
}
/**
* Connects new config to prerendered page
*/
connect: function connect({
config,
params,
}: {
config: PrefillAndIframeAttrsConfig;
// This is basically searchParams simplified as Record<string, string | string[]>
// So a=1&a=2&b=3 would be {a: ["1", "2"], b: "3"}
// We can't accept URLSearchParams as it isn't cloneable and thus postMessage doesn't support it
params: Record<string, string | string[]>;
}) {
log("Method: connect, requested with params", { config, params });
const { iframeAttrs: _1, ...queryParamsFromConfig } = config;
const connectVersion = (embedStore.connectVersion = embedStore.connectVersion + 1);
// We reset it to allow informing parent again through `__dimensionChanged` event about possibly updated dimensions with changes in config
embedStore.parentInformedAboutContentHeight = false;
connectPreloadedEmbed({ url: currentUrl });
// Config is just a typed and more declarative way to pass the query params from the parent(except iframeAttrs which is meant to be consumed by parent and not supposed to passed to child)
// So, query params can come directly by providing them to calLink or through config
const toBeThereParams = {
...params,
// Query params from config takes precedence over query params in url
...(queryParamsFromConfig as Record<string, string | string[]>),
"cal.embed.connectVersion": connectVersion.toString(),
};
(function tryToConnect() {
if (embedStore.prerenderState !== "completed") {
runAsap(tryToConnect);
return;
}
log("Method: connect, prerenderState is completed. Connecting");
connectPreloadedEmbed({
// We know after removing iframeAttrs, that it is of this type
toBeThereParams,
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
});
})();
},
};
@@ -457,6 +570,10 @@ const messageParent = (data: CustomEvent["detail"]) => {
);
};
/**
* This function is called once the iframe loads.
* It isn't called on "connect"
*/
function keepParentInformedAboutDimensionChanges() {
let knownIframeHeight: number | null = null;
let knownIframeWidth: number | null = null;
@@ -515,13 +632,14 @@ function keepParentInformedAboutDimensionChanges() {
const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
embedStore.parentInformedAboutContentHeight = true;
if (!iframeHeight || !iframeWidth) {
runAsap(informAboutScroll);
return;
}
if (knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth) {
const isThereAChangeInDimensions = knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth;
if (isThereAChangeInDimensions || !embedStore.parentInformedAboutContentHeight) {
embedStore.parentInformedAboutContentHeight = true;
knownIframeHeight = iframeHeight;
knownIframeWidth = iframeWidth;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
@@ -556,7 +674,7 @@ function main() {
actOnColorScheme(embedStore.uiConfig.colorScheme);
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
showPageAsNonEmbed();
// We would want to avoid a situation where Cal.com embeds cal.com and then embed-iframe is in the top as well. In such case, we would want to avoid infinite loop of events being passed.
log("Embed SDK Skipped as we are in top");
return;
@@ -588,6 +706,9 @@ function main() {
});
sdkActionManager?.on("*", (e) => {
if (isPrerendering() && !eventsAllowedInPrerendering.includes(e.detail.type)) {
return;
}
const detail = e.detail;
log(detail);
messageParent(detail);
@@ -601,7 +722,13 @@ function main() {
}
function initializeAndSetupEmbed() {
sdkActionManager?.fire("__iframeReady", {});
sdkActionManager?.fire("__iframeReady", {
isPrerendering: isPrerendering(),
});
if (isPrerendering()) {
embedStore.prerenderState = "inProgress";
}
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
@@ -642,19 +769,100 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
* 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
function connectPreloadedEmbed({
toBeThereParams,
toRemoveParams,
}: {
toBeThereParams: Record<string, string | string[]>;
toRemoveParams: string[];
}) {
const { hasChanged, stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams,
toRemoveParams,
});
let waitForFrames = 0;
if (isBookerReady() && hasChanged) {
// Give some time for react to update state that might lead booker to go to slotsLoading state
waitForFrames = 5;
}
// Booker might alreadyu be in slotsDone state. But we don't know if new getTeamSchedule request would intitiate or not. It would initiate when React updates the state but it might not go depending on if there is no actual state change in useSchedule components
// But we can know if cal.routedTeamMemberIds is changed. If it is changed, then we reset slotsDone -> slotsLoading.
// Firing this event would stop the loader and show the embed
// This causes loader to go away later.
runAsap(function tryToFireLinkReady() {
if (!isLinkReady() || waitForFrames > 0) {
waitForFrames--;
runAsap(tryToFireLinkReady);
return;
}
// link is ready now, so we could stop doing it.
// Also the page is visible to user now.
stopEnsuringQueryParamsInUrl();
sdkActionManager?.fire("linkReady", {});
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
});
}
const isPrerendering = () => {
return new URL(document.URL).searchParams.get("prerender") === "true";
};
export function getEmbedBookerState({
bookerState,
slotsQuery,
}: {
bookerState: BookerState;
slotsQuery: SlotsQuery;
}): EmbedBookerState {
if (bookerState === "loading") {
return "initializing";
}
if (slotsQuery.isLoading) {
return "slotsLoading";
}
// Pending but not loading, it means that request is intentionally disabled via enabled:false in useQuery
if (slotsQuery.isPending) {
return "slotsDone";
}
if (slotsQuery.isSuccess) {
return "slotsDone";
}
if (slotsQuery.isError) {
return "slotsLoadingError";
}
return "slotsPending";
}
/**
* It is meant to sync BookerState to EmbedBookerState
* This function is meant to be called outside useEffect so that we don't wait for React to re-render before doing our work
*/
export function updateEmbedBookerState({
bookerState,
slotsQuery,
}: {
bookerState: BookerState;
slotsQuery: SlotsQuery;
}) {
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
if (typeof window === "undefined") {
return;
}
const _window = window as Window & {
_embedBookerState?: EmbedBookerState;
};
const embedBookerState = getEmbedBookerState({ bookerState, slotsQuery });
_window._embedBookerState = embedBookerState;
}
main();
File diff suppressed because it is too large Load Diff
+433 -54
View File
@@ -3,20 +3,29 @@ import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import { addAppCssVars } from "./addAppCssVars";
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
import {
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
} from "./constants";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import css from "./embed.css";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig } from "./types";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
import { getMaxHeightForModal } from "./ui-utils";
import { fromEntriesWithDuplicateKeys, getConfigProp, generateDataAttributes } from "./utils";
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
import {
fromEntriesWithDuplicateKeys,
isRouterPath,
submitResponseAndGetRoutingResult,
generateDataAttributes,
getConfigProp,
isSameBookingLink,
} from "./utils";
// Exporting for consumption by @calcom/embed-core user
export type { EmbedEvent } from "./sdk-action-manager";
export type { PrefillAndIframeAttrsConfig } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[] | undefined> = T extends [any, ...infer U] ? U : never;
export type Message = {
@@ -36,13 +45,13 @@ customElements.define("cal-inline", Inline);
declare module "*.css";
type Namespace = string;
type InitConfig = {
type CalConfig = {
calOrigin: string;
debug?: boolean;
uiDebug?: boolean;
};
type InitArgConfig = Partial<InitConfig> & {
type InitArgConfig = Partial<CalConfig> & {
origin?: string;
};
@@ -193,7 +202,7 @@ type PrefillAndIframeAttrsConfigWithGuestAndColorScheme = PrefillAndIframeAttrsC
export class Cal {
iframe?: HTMLIFrameElement;
__config: InitConfig;
__config: CalConfig;
modalBox?: Element;
@@ -209,10 +218,14 @@ export class Cal {
api: CalApi;
isPerendering?: boolean;
isPrerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
// Store calLink separately and not rely on deriving it from iframe.src, because we could load different URL in iframe(derived from calLink e.g. calLink=Router -> redirects to eventBookingUrl and then we load that URL in iframe)
calLink: string | null = null;
embedConfig: PrefillAndIframeAttrsConfig | null = null;
// Tracks the time when the embed was last rendered with some changes to iframe i.e. it identifies if the iframe is freshly updated and when
embedRenderStartTime: number | null = null;
static ensureGuestKey(config: PrefillAndIframeAttrsConfig) {
config = config || {};
return {
@@ -237,7 +250,7 @@ export class Cal {
const [method, ...args] = instruction;
if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`);
error(`Instruction ${method} not FOUND`);
}
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -245,7 +258,7 @@ export class Cal {
this.api[method](...args);
} catch (e) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e);
error(`Instruction couldn't be executed`, e);
}
return instruction;
}
@@ -280,7 +293,25 @@ export class Cal {
iframe.className = "cal-embed";
iframe.name = `cal-embed=${this.namespace}`;
iframe.title = `Book a call`;
const embedConfig = this.getInitConfig();
this.loadInIframe({ calLink, config, calOrigin, iframe });
return iframe;
}
loadInIframe({
calLink,
config = {},
calOrigin,
iframe,
}: {
iframe: HTMLIFrameElement;
calLink: string;
config?: PrefillAndIframeAttrsConfig;
calOrigin: string | null;
}) {
log("Loading in iframe", calLink);
iframe.dataset.calLink = calLink;
const calConfig = this.getCalConfig();
const { iframeAttrs, ...queryParamsFromConfig } = config;
if (iframeAttrs && iframeAttrs.id) {
@@ -290,10 +321,11 @@ export class Cal {
const searchParams = this.buildFilteredQueryParams(queryParamsFromConfig);
// cal.com has rewrite issues on Safari that sometimes cause 404 for assets.
const originToUse = (calOrigin || embedConfig.calOrigin || "").replace(
const originToUse = (calOrigin || calConfig.calOrigin || "").replace(
"https://cal.com",
"https://app.cal.com"
);
const urlInstance = new URL(`${originToUse}/${calLink}`);
if (!urlInstance.pathname.endsWith("embed")) {
// TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding"
@@ -302,14 +334,14 @@ export class Cal {
urlInstance.searchParams.set("embed", this.namespace);
if (embedConfig.debug) {
urlInstance.searchParams.set("debug", `${embedConfig.debug}`);
if (calConfig.debug) {
urlInstance.searchParams.set("debug", `${calConfig.debug}`);
}
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
iframe.style.visibility = "hidden";
if (embedConfig.uiDebug) {
if (calConfig.uiDebug) {
iframe.style.border = "1px solid green";
}
@@ -319,11 +351,26 @@ export class Cal {
for (const [key, value] of searchParams) {
urlInstance.searchParams.append(key, value);
}
// Very Important:Reset iframe ready flag, as iframe might load a fresh URL and we need to check when it is ready.
this.iframeReady = false;
if (iframe.src === urlInstance.toString()) {
// Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it
// This param has no other purpose except to ensure forced reload.
urlInstance.searchParams.append("__cal.reloadTs", Date.now().toString());
}
iframe.src = urlInstance.toString();
return iframe;
}
getInitConfig() {
/**
* Returns the config applicable to entire Cal namespace.
* Individual embeds can have their own embed config passed via `config` prop normally.
* Also, calOrigin could be passed individually as well at the moment
*/
getCalConfig() {
return this.__config;
}
@@ -380,15 +427,14 @@ export class Cal {
}
});
this.actionManager.on("__iframeReady", () => {
this.actionManager.on("__iframeReady", (e) => {
this.iframeReady = true;
if (this.iframe) {
if (this.iframe && !e.detail.data.isPrerendering) {
// It's a bit late to make the iframe visible here. We just needed to wait for the HTML tag of the embedded calLink to be rendered(which then informs the browser of the color-scheme)
// Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
// TODO: Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
// But it's okay to do it here for now because the embedded calLink also keeps itself hidden till it receives `parentKnowsIframeReady` message(It has it's own reasons for that)
// Once the embedded calLink starts not hiding the document, we should optimize this line to make the iframe visible earlier than this.
// Imp: Don't use visibility:visible as that would make the iframe show even if the host element(A paren tof the iframe) has visibility:hidden set. Just reset the visibility to default
// Imp: Don't use visibility:visible as that would make the iframe show even if the host element(A parent of the iframe) has visibility:hidden set. Just reset the visibility to default
this.iframe.style.visibility = "";
}
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
@@ -411,10 +457,14 @@ 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
if (this.isPrerendering) {
// Ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed could show-up without any user action
return;
}
this.iframe!.style.visibility = "";
// Removes the loader
// TODO: We should be using consistent approach of "state" attribute for modalBox and inlineEl.
this.modalBox?.setAttribute("state", "loaded");
this.inlineEl?.setAttribute("loading", "done");
});
@@ -424,6 +474,9 @@ export class Cal {
if (!iframe) {
return;
}
if (this.isPrerendering) {
return;
}
this.inlineEl?.setAttribute("data-error-code", e.detail.data.code);
this.modalBox?.setAttribute("data-error-code", e.detail.data.code);
this.inlineEl?.setAttribute("loading", "failed");
@@ -461,13 +514,247 @@ export class Cal {
return searchParams;
}
getNextActionForModal({
modal,
pathWithQueryToLoad,
stateData,
}: {
modal: { uid: string };
pathWithQueryToLoad: string;
stateData: {
embedConfig: PrefillAndIframeAttrsConfig;
previousEmbedConfig: PrefillAndIframeAttrsConfig | null;
isConnectionInitiated: boolean;
previousEmbedRenderStartTime: number | null;
embedRenderStartTime: number;
};
}) {
const {
embedConfig,
previousEmbedConfig,
isConnectionInitiated,
previousEmbedRenderStartTime,
embedRenderStartTime,
} = stateData;
const calConfig = this.getCalConfig();
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
const urlToLoadObject = new URL(pathWithQueryToLoad, calConfig.calOrigin as string);
const existingModalEl = document.querySelector(`cal-modal-box[uid="${modal.uid}"]`);
const urlToLoadPath = urlToLoadObject.pathname;
// We only check for path because query params are handled by connect flow
// Also origin we assume never changes without page reload of the embedding page
const isSameCalLink =
lastLoadedPathInIframe &&
isSameBookingLink({
bookingLinkPath1: lastLoadedPathInIframe,
bookingLinkPath2: urlToLoadPath,
});
const lastLoadedUrlInIframeObjectSearchParams = lastLoadedUrlInIframeObject?.searchParams.toString();
const urlToLoadObjectSearchParams = urlToLoadObject.searchParams.toString();
const areSameQueryParams = lastLoadedUrlInIframeObjectSearchParams === urlToLoadObjectSearchParams;
const isSameConfig =
previousEmbedConfig &&
isSameEmbedConfig({
embedConfig1: previousEmbedConfig,
embedConfig2: embedConfig,
});
const isInFailedState = existingModalEl && existingModalEl.getAttribute("state") === "failed";
const timeSinceLastRender = previousEmbedRenderStartTime
? embedRenderStartTime - previousEmbedRenderStartTime
: 0;
const crossedReloadThreshold = previousEmbedRenderStartTime
? timeSinceLastRender > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
: false;
const areSlotsStale = previousEmbedRenderStartTime
? timeSinceLastRender > EMBED_MODAL_IFRAME_SLOT_STALE_TIME
: false;
// Note that we don't worry about change in embed config because that is passed on as query params to the iframe and that is already supported by "connect" flow
const isResetNeeded = !isSameCalLink || isInFailedState || crossedReloadThreshold;
const actionToTake = isResetNeeded
? "fullReload"
: !isSameConfig || !areSameQueryParams || !isConnectionInitiated || areSlotsStale
? "connect"
: "noAction";
log("Next Modal Action:", actionToTake, {
path: {
isSame: isSameCalLink,
urlToLoadPath,
lastLoadedPathInIframe,
},
config: {
isSame: isSameConfig,
previousEmbedConfig,
embedConfig,
},
queryParams: {
isSame: areSameQueryParams,
lastLoadedUrlInIframeObjectSearchParams,
urlToLoadObjectSearchParams,
},
areSlotsStale,
crossedReloadThreshold,
isInFailedState,
isConnectionInitiated,
});
return actionToTake;
function isSameEmbedConfig({
embedConfig1,
embedConfig2,
}: {
embedConfig1: PrefillAndIframeAttrsConfig;
embedConfig2: PrefillAndIframeAttrsConfig;
}) {
if (Object.keys(embedConfig1).length !== Object.keys(embedConfig2).length) {
return false;
}
// Verify the two config have all props as same
return Object.keys(embedConfig1).every((key) => {
if (typeof embedConfig1[key] !== typeof embedConfig2[key]) {
return false;
}
// Now we know both have same type.
const embedConfig1Value = embedConfig1[key];
const embedConfig2Value = embedConfig2[key];
if (embedConfig1Value instanceof Array && embedConfig2Value instanceof Array) {
return (
embedConfig1Value.length === embedConfig2Value.length &&
embedConfig1Value.every((value: string) => embedConfig2Value.includes(value))
);
}
if (typeof embedConfig1Value === "string") {
return embedConfig1Value === embedConfig2Value;
}
return true;
});
}
}
/**
* Returns the last loaded URL in iframe.
* Removes /embed from the pathname and returns the origin.
*/
getLastLoadedLinkInframe() {
if (!this.iframe || !this.iframe.dataset.calLink) {
return null;
}
const calLink = this.iframe.dataset.calLink;
if (!calLink) {
return null;
}
const urlObject = new URL(calLink, new URL(this.iframe.src).origin);
return new URL(`${urlObject.pathname}${urlObject.search}`, urlObject.origin);
}
async submitThroughHeadlessRouterInModal({
modal,
calLinkUrlObject,
stateData,
}: {
modal: { uid: string; element: Element; calOrigin: string | null };
calLinkUrlObject: URL;
stateData: {
embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme;
previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null;
embedRenderStartTime: number;
previousEmbedRenderStartTime: number | null;
isConnectionInitiated: boolean;
};
}) {
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
const { embedConfig } = stateData;
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
const calConfig = this.getCalConfig();
const calOrigin = _calOrigin ?? calConfig.calOrigin;
const headlessRouterPageObject = calLinkUrlObject;
const result = await submitResponseAndGetRoutingResult({
headlessRouterPageUrl: headlessRouterPageObject.toString(),
});
log("Headless router result", result);
if ("redirect" in result) {
const routerRedirectUrl = new URL(result.redirect);
const paramsFromRedirect = fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries());
const newEmbedConfig = (this.embedConfig = {
...embedConfig,
...paramsFromRedirect,
});
const actionToTake = this.getNextActionForModal({
modal: { uid: modalBoxUid },
pathWithQueryToLoad: `${routerRedirectUrl.pathname}${routerRedirectUrl.search}`,
stateData: {
...stateData,
embedConfig: newEmbedConfig,
},
});
if (actionToTake === "fullReload") {
if (lastLoadedPathInIframe) {
console.error("Preloaded iframe couldn't be used", {
preloadedPath: lastLoadedPathInIframe,
newPath: routerRedirectUrl.pathname,
});
}
const pathWithoutStartingSlash = routerRedirectUrl.pathname.replace(/^\//, "");
this.loadInIframe({
calLink: pathWithoutStartingSlash,
calOrigin,
config: newEmbedConfig,
iframe: this.iframe as HTMLIFrameElement,
});
} else {
log("Reusing the same iframe for headless router", {
routerRedirectUrl: routerRedirectUrl.toString(),
lastLoadedPathInIframe,
});
// Connection Initiated
this.doInIframe({
method: "connect",
arg: {
config: newEmbedConfig,
params: fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries()),
},
});
}
} else if ("message" in result) {
log("Setting message in modal", {
message: result.message,
});
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.message);
modalEl.setAttribute("state", "has-message");
} else if ("error" in result) {
log("Setting error in modal", {
error: result.error,
});
// We need to show this message in the modal
modalEl.setAttribute("data-error-code", "routerError");
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.error);
modalEl.setAttribute("state", "failed");
}
}
}
class CalApi {
cal: Cal;
static initializedNamespaces = [] as string[];
modalUid?: string;
preloadedModalUid?: string;
prerenderedModalUid?: string;
constructor(cal: Cal) {
this.cal = cal;
}
@@ -479,7 +766,7 @@ class CalApi {
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
let initForNamespace = "";
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as InitConfig;
config = (namespaceOrConfig || {}) as CalConfig;
} else {
initForNamespace = namespaceOrConfig;
}
@@ -560,7 +847,7 @@ class CalApi {
}
config.embedType = "inline";
const calConfig = this.cal.getInitConfig();
const calConfig = this.cal.getCalConfig();
const iframe = this.cal.createIframe({
calLink,
@@ -654,7 +941,7 @@ class CalApi {
dataset["buttonTextColor"] = `${buttonTextColor}`;
}
modal({
async modal({
calLink,
config = {},
calOrigin,
@@ -665,16 +952,22 @@ class CalApi {
calOrigin?: string;
__prerender?: boolean;
}) {
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
// `this.prerenderedModalUid` is set for a modal created through "prerender"
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
// Means whether there is already an attempt to use the prerendered modal
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
const containerEl = document.body;
this.cal.isPerendering = !!__prerender;
this.cal.isPrerendering = !!__prerender;
if (__prerender) {
// Add preload query param
// Add prerender query param
config.prerender = "true";
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true";
}
const configWithGuestKeyAndColorScheme = withColorScheme(
@@ -684,29 +977,91 @@ class CalApi {
}),
containerEl
);
const calConfig = this.cal.getCalConfig();
// calOrigin could have been passed as empty string by the user
calOrigin = calOrigin || calConfig.calOrigin;
const embedRenderStartTime = Date.now();
const previousEmbedConfig = this.cal.embedConfig;
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
if (isConnectingToPreloadedModal) {
this.cal.doInIframe({
method: "connect",
arg: configWithGuestKeyAndColorScheme,
});
this.modalUid = uid;
// isConnectionPossible
if (!!existingModalEl && !!this.cal.iframe) {
const calLinkUrlObject = new URL(calLink, calOrigin);
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
log(`Trying to reuse modal ${uid}`);
const stateData = {
embedConfig: configWithGuestKeyAndColorScheme,
previousEmbedConfig,
embedRenderStartTime,
previousEmbedRenderStartTime,
isConnectionInitiated,
};
if (isHeadlessRouterPath) {
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading");
return;
// submitThroughHeadlessRouterInModal would further decide whether full page reload is needed or a connect would suffice
// actionToTake might be "fullReload" and still connect could work in case of headless router because there might be just query params change(which can be handled by connect) of calLink(i.e. ?form=formid&newParam=newValue)
await this.cal.submitThroughHeadlessRouterInModal({
modal: { uid, element: existingModalEl, calOrigin },
calLinkUrlObject,
stateData,
});
} else {
existingModalEl.setAttribute("state", "reopening");
return;
const actionToTake = this.cal.getNextActionForModal({
modal: { uid },
pathWithQueryToLoad: `${calLinkUrlObject.pathname}${calLinkUrlObject.search}`,
stateData,
});
if (actionToTake === "noAction") {
log(`Reopening modal without any other action needed ${uid}`);
// Reopen the modal, nothing else to do
existingModalEl.setAttribute("state", "reopened");
return;
}
log("Attempting to load/connect regular booking link");
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading");
if (actionToTake === "fullReload") {
log("Initiating full page load");
this.cal.loadInIframe({
calLink,
calOrigin,
iframe: this.cal.iframe,
config: configWithGuestKeyAndColorScheme,
});
} else if (actionToTake === "connect") {
this.cal.doInIframe({
method: "connect",
arg: {
config: configWithGuestKeyAndColorScheme,
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
},
});
}
}
// We reach here in case of connect or fullReload
this.modalUid = uid;
this.cal.embedRenderStartTime = embedRenderStartTime;
return;
}
log(`Creating new modal ${uid}`);
if (__prerender) {
this.preloadedModalUid = uid;
this.prerenderedModalUid = uid;
} else {
// Intentionally not setting it to have the behaviour of reusing the same modal. Because it causes outdated content that might not be valid based on
// 1. The time difference b/w reopening(availability getting changed in b/w)
// 2. User using different query params but they not being used because of the same modal being reused. Happens in case of headless router being opened in embed
// Intentionally not setting it to avoid the behaviour of reusing the same modal. It was disabled earlier but now can be enabled but we will enable it later.
// this.modalUid = uid;
}
@@ -720,7 +1075,7 @@ class CalApi {
iframe = this.cal.createIframe({
calLink,
config: configWithGuestKeyAndColorScheme,
calOrigin: calOrigin || null,
calOrigin,
});
}
@@ -742,6 +1097,9 @@ class CalApi {
</cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
// Set state through setAttribute so that onAttributeChangedCallback is triggered
this.cal.modalBox.setAttribute("state", "loading");
if (__prerender) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
@@ -800,12 +1158,14 @@ class CalApi {
calLink,
type,
options = {},
pageType,
}: {
calLink: string;
type?: "modal" | "floatingButton";
options?: {
prerenderIframe?: boolean;
};
pageType?: EmbedPageType;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
@@ -835,7 +1195,7 @@ class CalApi {
throw new Error(`Namespace ${namespace} isn't defined`);
}
const config = this.cal.getInitConfig();
const config = this.cal.getCalConfig();
let prerenderIframe = options.prerenderIframe;
if (type && prerenderIframe === undefined) {
prerenderIframe = true;
@@ -847,11 +1207,12 @@ class CalApi {
if (prerenderIframe) {
if (type === "modal" || type === "floatingButton") {
this.cal.isPerendering = true;
this.cal.isPrerendering = true;
this.modal({
calLink,
calOrigin: config.calOrigin,
__prerender: true,
...(pageType ? { config: { "cal.embed.pageType": pageType } } : {}),
});
} else {
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
@@ -862,10 +1223,19 @@ class CalApi {
}
}
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
prerender({
calLink,
type,
pageType,
}: {
calLink: string;
type: "modal" | "floatingButton";
pageType?: EmbedPageType;
}) {
this.preload({
calLink,
type,
pageType,
});
}
@@ -1050,7 +1420,7 @@ function getEmbedApiFn(ns: string) {
return api;
}
function preloadAssetsForCalLink({ config, calLink }: { config: InitConfig; calLink: string }) {
function preloadAssetsForCalLink({ config, calLink }: { config: CalConfig; calLink: string }) {
const iframe = document.body.appendChild(document.createElement("iframe"));
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
@@ -1081,5 +1451,14 @@ function initializeGlobalCalProps() {
}
function log(...args: unknown[]) {
console.log(...args);
const searchString = location.search;
globalCal.__logQueue = globalCal.__logQueue || [];
globalCal.__logQueue.push(args);
if (searchString.includes("cal.embed.logging=1") || process.env.INTEGRATION_TEST_MODE === "true") {
console.log("Parent:", ...args);
}
}
function error(...args: unknown[]) {
console.error(...args);
}
@@ -101,7 +101,9 @@ export type EventDataMap = {
__routeChanged: Record<string, never>;
__windowLoadComplete: Record<string, never>;
__closeIframe: Record<string, never>;
__iframeReady: Record<string, never>;
__iframeReady: {
isPrerendering: boolean;
};
__dimensionChanged: {
iframeHeight: number;
iframeWidth: number;
+28 -1
View File
@@ -69,4 +69,31 @@ export type KnownConfig = {
"cal.embed.pageType"?: EmbedPageType;
};
export {};
export type EmbedBookerState =
| "initializing"
| "slotsPending"
| "slotsLoading"
/**
* When slots have loaded
* Even when slots aren't requested, due to cal.skipSlotFetch, then also this state is achieved
*/
| "slotsDone"
| "slotsLoadingError";
export type SlotsStatus = "loading" | "success" | "error";
export type SlotsQuery = {
isPending?: boolean;
isError?: boolean;
isSuccess?: boolean;
isLoading?: boolean;
};
/**
* 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;
};
} & KnownConfig;
+102 -4
View File
@@ -1,10 +1,22 @@
import type { KnownConfig } from "./types";
import type { KnownConfig, PrefillAndIframeAttrsConfig } from "./types";
export const getErrorString = (errorCode: string | undefined) => {
export const getErrorString = ({
errorCode,
errorMessage,
}: {
errorCode: string | undefined;
errorMessage: string | undefined;
}) => {
const defaultErrorMessage = "Something went wrong.";
if (errorCode === "404") {
return `Error Code: 404. Cal Link seems to be wrong.`;
errorMessage = errorMessage ?? "Cal Link seems to be wrong.";
return `Error Code: 404. ${errorMessage}`;
} else if (errorCode === "routerError") {
errorMessage = errorMessage ?? defaultErrorMessage;
return `Error Code: routerError. ${errorMessage}`;
} else {
return `Error Code: ${errorCode}. Something went wrong.`;
errorMessage = errorMessage ?? defaultErrorMessage;
return `Error Code: ${errorCode}. ${errorMessage}`;
}
};
@@ -38,6 +50,28 @@ export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string,
return result;
}
export function isParamValuePresentInUrlSearchParams({
param,
value,
container,
}: {
param: string;
value: string | string[];
container: URLSearchParams;
}) {
// Because UrlSearchParams could have multiple entries with same key like guest=1&guest=2
const containerEntries = fromEntriesWithDuplicateKeys(container.entries());
if (!container.has(param)) {
return false;
}
const containerValue = containerEntries[param];
const valueArray = Array.isArray(value) ? value : [value];
if (valueArray.length !== containerValue.length) {
return false;
}
return valueArray.every((valueItem) => containerValue.includes(valueItem));
}
function listKnownConfigProps() {
const knownConfigProps: (keyof KnownConfig)[] = [
"flag.coep",
@@ -98,3 +132,67 @@ export function generateDataAttributes(props: Record<string, string | null | und
.map(([key, value]) => `data-${hyphenate(key)}="${escapeHtmlAttribute(value)}"`)
.join(" ");
}
export function isRouterPath(path: string) {
// URL doesn't matter
const urlObject = new URL(path, "http://baseUrl.example");
return urlObject.pathname === "/router";
}
export async function submitResponseAndGetRoutingResult({
headlessRouterPageUrl,
}: {
headlessRouterPageUrl: string;
}) {
const headlessRouterUrlObject = new URL(headlessRouterPageUrl);
const searchParams = headlessRouterUrlObject.searchParams;
const headlessRouterApiUrl = `${headlessRouterUrlObject.origin}${headlessRouterUrlObject.pathname.replace(
/^\/?router/,
"/api/router"
)}`;
const response = await fetch(headlessRouterApiUrl, {
method: "POST",
body: JSON.stringify(fromEntriesWithDuplicateKeys(searchParams.entries())),
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.status === "success") {
if (!result.data.redirect && !result.data.message) {
throw new Error("No `redirect` or `message` in response");
}
return result.data as { redirect: string } | { message: string };
} else {
if (!result.data.message) {
throw new Error("No `message` in response");
}
console.warn("Error submitting response and getting routing result", result);
return { error: result.data.message } as { error: string };
}
}
export function isSameBookingLink({
bookingLinkPath1,
bookingLinkPath2,
}: {
bookingLinkPath1: string;
bookingLinkPath2: string;
}) {
// Headless router redirects to /team/event-booking-url at the moment. In future it might fix it to /event-booking-url
// So, stripe /team from both the URLs if present so that they can be compared easily
return bookingLinkPath1.replace(/^\/team\//, "/") === bookingLinkPath2.replace(/^\/team\//, "/");
}
export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig) {
const { iframeAttrs: _1, ...params } = config;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value instanceof Array) {
value.forEach((val) => searchParams.append(key, val));
} else if (typeof value === "string") {
searchParams.set(key, value);
}
}
return searchParams;
}
+3 -10
View File
@@ -8,6 +8,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
import dayjs from "@calcom/dayjs";
import PoweredBy from "@calcom/ee/components/PoweredBy";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
@@ -38,18 +39,10 @@ import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/us
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import framerFeatures from "./framer-features";
import { useBookerStore } from "./store";
import type { BookerProps, WrappedBookerProps, BookerState } from "./types";
import type { BookerProps, WrappedBookerProps } from "./types";
import { isBookingDryRun } from "./utils/isBookingDryRun";
import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable";
function updateEmbedBookerState({ bookerState }: { bookerState: BookerState }) {
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
if (typeof window !== "undefined") {
(window as Window & { _embedBookerState?: "initializing" | "done" })._embedBookerState =
bookerState && bookerState !== "loading" ? "done" : "initializing";
}
}
const BookerComponent = ({
username,
eventSlug,
@@ -186,7 +179,7 @@ const BookerComponent = ({
(bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep))
);
updateEmbedBookerState({ bookerState });
updateEmbedBookerState({ bookerState, slotsQuery: schedule });
useEffect(() => {
if (event.isPending) return setBookerState("loading");
@@ -1,5 +1,7 @@
import { useSearchParams } from "next/navigation";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants";
@@ -39,6 +41,8 @@ export const useSchedule = ({
orgSlug,
teamMemberEmail,
}: UseScheduleWithCacheArgs) => {
const bookerState = useBookerStore((state) => state.state);
const [startTime, endTime] = useTimesForSchedule({
month,
monthCount,
@@ -56,11 +60,13 @@ export const useSchedule = ({
const utils = trpc.useUtils();
const routingFormResponseIdParam = searchParams?.get("cal.routingFormResponseId");
const email = searchParams?.get("email");
// We allow skipping the schedule fetch as a requirement for prerendering in iframe through embed as when the pre-rendered iframe is connected, then we would fetch the availability, which would be upto-date
// Also, a reuse through Headless Router could completely change the availability as different team members are selected and thus it is unnecessary to fetch the schedule
const skipGetSchedule = searchParams?.get("cal.skipSlotsFetch") === "true";
const routingFormResponseId = routingFormResponseIdParam
? parseInt(routingFormResponseIdParam, 10)
: undefined;
const embedConnectVersion = searchParams?.get("cal.embed.connectVersion") || "";
const input = {
isTeamEvent,
usernameList: getUsernameList(username ?? ""),
@@ -83,6 +89,8 @@ export const useSchedule = ({
_shouldServeCache,
routingFormResponseId,
email,
// Ensures that connectVersion causes a refresh of the data
...(embedConnectVersion ? { embedConnectVersion } : {}),
};
const options = {
@@ -98,6 +106,7 @@ export const useSchedule = ({
// It allows long sitting users to get latest available slots
refetchInterval: PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS * 1000,
enabled:
!skipGetSchedule &&
Boolean(username) &&
Boolean(month) &&
Boolean(timezone) &&
@@ -111,6 +120,12 @@ export const useSchedule = ({
} else {
schedule = trpc.viewer.slots.getSchedule.useQuery(input, options);
}
updateEmbedBookerState({
bookerState,
slotsQuery: schedule,
});
return {
...schedule,
/**
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
type Handlers = {
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>;
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "OPTIONS"]?: Promise<{ default: NextApiHandler }>;
};
/** Allows us to split big API handlers by method */
+6 -3
View File
@@ -48,7 +48,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
};
if (!queryParsed.success) {
log.warn("Error parsing query", queryParsed.error);
log.warn("Error parsing query", { issues: queryParsed.error.issues });
return {
notFound: true,
};
@@ -150,7 +150,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
props: {
...pageProps,
form: serializableForm,
message: e.message,
message: null,
errorMessage: e.message,
},
};
}
@@ -166,6 +167,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
...pageProps,
form: serializableForm,
message: decidedAction.value,
errorMessage: null,
},
};
} else if (decidedAction.type === "eventTypeRedirectUrl") {
@@ -214,7 +216,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
props: {
...pageProps,
form: serializableForm,
message: "Unhandled type of action",
message: null,
errorMessage: "Unhandled type of action",
},
};
};
+36 -13
View File
@@ -194,6 +194,15 @@ async function main() {
});
}
const insightsTeamMembers = await prisma.membership.findMany({
where: {
teamId: insightsTeam.id,
},
include: {
user: true,
},
});
// Create event types for the team
let teamEvents = await prisma.eventType.findMany({
where: {
@@ -207,6 +216,7 @@ async function main() {
length: true,
teamId: true,
userId: true,
assignAllTeamMembers: true,
},
});
@@ -242,6 +252,7 @@ async function main() {
},
],
});
teamEvents = await prisma.eventType.findMany({
where: {
teamId: insightsTeam.id,
@@ -254,20 +265,32 @@ async function main() {
length: true,
teamId: true,
userId: true,
assignAllTeamMembers: true,
},
});
// After creating or fetching teamEvents and insightsTeamMembers
// Create Host records for all team members for each event with assignAllTeamMembers true
const assignAllTeamMembersEvents = teamEvents.filter((event) => (event as any).assignAllTeamMembers);
if (assignAllTeamMembersEvents.length > 0 && insightsTeamMembers.length > 0) {
const hostRecords = assignAllTeamMembersEvents.flatMap((event) =>
insightsTeamMembers.map((member) => ({
userId: member.user.id,
eventTypeId: event.id,
isFixed: false,
}))
);
if (hostRecords.length > 0) {
await prisma.host.createMany({
data: hostRecords,
skipDuplicates: true,
});
}
}
}
const javascriptEventId = teamEvents.find((event) => event.slug === "team-javascript")?.id;
const salesEventId = teamEvents.find((event) => event.slug === "team-sales")?.id;
const insightsMembers = await prisma.membership.findMany({
where: {
teamId: insightsTeam.id,
},
include: {
user: true,
},
});
// Create bookings for the team events
const baseBooking = {
@@ -276,7 +299,7 @@ async function main() {
description: "Team Meeting Should be changed in shuffle",
startTime: dayjs().toISOString(),
endTime: dayjs().toISOString(),
userId: insightsMembers[0].user.id, // Use first org member as default
userId: insightsTeamMembers[0].user.id, // Use first org member as default
};
// Create past bookings -2y, -1y, -0y
@@ -287,7 +310,7 @@ async function main() {
{ ...baseBooking },
dayjs().get("y") - 2,
teamEvents,
insightsMembers.map((m) => m.user.id)
insightsTeamMembers.map((m) => m.user.id)
)
),
],
@@ -300,7 +323,7 @@ async function main() {
{ ...baseBooking },
dayjs().get("y") - 1,
teamEvents,
insightsMembers.map((m) => m.user.id)
insightsTeamMembers.map((m) => m.user.id)
)
),
],
@@ -313,7 +336,7 @@ async function main() {
{ ...baseBooking },
dayjs().get("y"),
teamEvents,
insightsMembers.map((m) => m.user.id)
insightsTeamMembers.map((m) => m.user.id)
)
),
],
@@ -331,7 +354,7 @@ async function main() {
}
const seededForm = await seedRoutingForms(
insightsTeam.id,
owner?.user.id ?? insightsMembers[0].user.id,
owner?.user.id ?? insightsTeamMembers[0].user.id,
attributes
);
+41 -9
View File
@@ -469,7 +469,7 @@ export async function seedRoutingForms(
},
});
if (form) {
console.log(`Skipping Routing Form - Form Seed, "Seeded Form - Pro" already exists`);
console.log(`Skipping Routing Form - Form Seed, ${seededForm.name} already exists`);
return;
}
@@ -477,6 +477,16 @@ export async function seedRoutingForms(
throw new Error("No attributes found - Please call seedAttributes first");
}
const formFieldSkillsOptions = attributeRaw[2].options.map((opt) => ({
id: opt.id,
label: opt.value,
}));
const formFieldLocationOptions = attributeRaw[1].options.map((opt) => ({
id: opt.id,
label: opt.value,
}));
await prisma.app_RoutingForms_Form.create({
data: {
id: seededForm.id,
@@ -491,6 +501,21 @@ export async function seedRoutingForms(
queryValue: {
id: "aaba9988-cdef-4012-b456-719300f53ef8",
type: "group",
children1: {
"b98b98a8-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: seededForm.formFieldSkills.id,
value: [
formFieldSkillsOptions.filter((opt) => opt.label === "JavaScript").map((opt) => opt.id),
],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
attributesQueryValue: {
id: "ab99bbb9-89ab-4cde-b012-319300f53ef8",
@@ -522,6 +547,19 @@ export async function seedRoutingForms(
queryValue: {
id: "aaba9948-cdef-4012-b456-719300f53ef8",
type: "group",
children1: {
"c98b98a8-1123-4456-e89a-a19300f55277": {
type: "rule",
properties: {
field: seededForm.formFieldSkills.id,
value: [formFieldSkillsOptions.filter((opt) => opt.label === "Sales").map((opt) => opt.id)],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
attributesQueryValue: {
id: "ab988888-89ab-4cde-b012-319300f53ef8",
@@ -559,20 +597,14 @@ export async function seedRoutingForms(
id: seededForm.formFieldLocation.id,
type: "select",
label: "Location",
options: attributeRaw[1].options.map((opt) => ({
id: opt.id,
label: opt.value,
})),
options: formFieldLocationOptions,
required: true,
},
{
id: seededForm.formFieldSkills.id,
type: "multiselect",
label: "skills",
options: attributeRaw[2].options.map((opt) => ({
id: opt.id,
label: opt.value,
})),
options: formFieldSkillsOptions,
required: true,
},
{