From 22f136d19b23b74273c0d94fcf4eef70713ee711 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:11:49 -0400 Subject: [PATCH] feat: Headless router - queue recording booking response (#21805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add queued booking response table * Create `RoutingFormResponseRepository` * Pass `queueFormResponse` param * Queue up form response if param is passed * Forward queued form response parma to booker * Pass `queuedFormResponse` from booker to `handleNewBooking` * Write queued routing form response * Type fixes * Clean up * Allow dry run to work which wont have any QueuedFormResponse or FormResponse * Support passing the time when the modal was actually shown to the user and consider that time as the time of form submission * fix ts error * Queue -> Response through separate endpoint that would be triggered by embed * Make queueResponseId a non-guessable uid * Change queueFormResponse query param * fix ts error * Support useQueuedResponse to record new response data * revert handleNewBooking * Remove dead code formResponse * Refactor use repository * Unify migration files * refactor: moved api endpoint to app dir Signed-off-by: Omar López * Update formResponse.ts * Refactor use-queued-response for test * Add tests * Fix ts error and unit test. recordFormResponse cant return nullish response * fix schema * feat: Support full reuse of preloaded iframe (#21803) * feat: support updating cal video settings in API v2 (#21784) * feat: support updating cal video settings in API v2 * chore: update descriptio * feat: support create event type * test: add test for updating event type * test: add test for create event type * chore: undo openapi * chore: bump libraries * Revert "chore: bump libraries" This reverts commit bdf36d09b021fc531497a7b7ea66ab9c52b7d136. * chore: bump libraries --------- Co-authored-by: Lauris Skraucis Co-authored-by: supalarry * fix tests and ts * Fix tests * wip-useQueuedResponseEndpoint * Add one more test * Change queueFormResponse query param * wip * Support useQueuedResponse to record new response data * Use the update useQueuedResponse endpoint * self-review addressed * Use queuedResponse if available in slots/utils * Add documentation * Remove use-queued-response from critical-path --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Lauris Skraucis Co-authored-by: supalarry * Update schema.prisma * refactor: renamed to avoid react hooks confusion Signed-off-by: Omar López --------- Signed-off-by: Omar López Co-authored-by: Hariom Balhara Co-authored-by: Omar López Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Lauris Skraucis Co-authored-by: supalarry Co-authored-by: Peer Richelsen --- .../__tests__/queued-response.test.ts | 152 ++++++++++ .../routing-forms/queued-response/route.ts | 110 +++++++ .../routing-forms/lib/formSubmissionUtils.ts | 72 +++++ .../routing-forms/lib/getResponseToStore.ts | 26 ++ .../routing-forms/lib/handleResponse.ts | 79 ++--- .../pages/routing-link/[...appPages].tsx | 4 +- .../getUrlSearchParamsToForward.test.ts | 7 + .../getUrlSearchParamsToForward.ts | 11 +- packages/embeds/embed-core/index.html | 35 +-- .../playground/lib/playground-init.ts | 21 ++ .../{ => playground/lib}/playground.ts | 2 +- .../embeds/embed-core/routing-playground.html | 135 ++++++--- .../src/__tests__/embed-iframe.test.ts | 172 +++++++++-- packages/embeds/embed-core/src/constants.ts | 8 +- .../embeds/embed-core/src/embed-iframe.ts | 285 +++++++----------- .../src/embed-iframe/lib/embedStore.ts | 118 ++++++++ .../embed-core/src/embed-iframe/lib/utils.ts | 58 ++++ packages/embeds/embed-core/src/embed.test.ts | 98 +++++- packages/embeds/embed-core/src/embed.ts | 207 +++++++++---- .../embed-core/src/sdk-action-manager.ts | 2 + packages/embeds/embed-core/src/types.ts | 5 + .../schedules/lib/use-schedule/useSchedule.ts | 5 +- packages/lib/server/getRoutedUrl.ts | 32 +- .../lib/server/repository/formResponse.ts | 126 ++++++++ .../migration.sql | 21 ++ packages/prisma/schema.prisma | 18 +- .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 22 +- 28 files changed, 1408 insertions(+), 424 deletions(-) create mode 100644 apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts create mode 100644 apps/web/app/api/routing-forms/queued-response/route.ts create mode 100644 packages/app-store/routing-forms/lib/formSubmissionUtils.ts create mode 100644 packages/app-store/routing-forms/lib/getResponseToStore.ts create mode 100644 packages/embeds/embed-core/playground/lib/playground-init.ts rename packages/embeds/embed-core/{ => playground/lib}/playground.ts (99%) create mode 100644 packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts create mode 100644 packages/embeds/embed-core/src/embed-iframe/lib/utils.ts create mode 100644 packages/lib/server/repository/formResponse.ts create mode 100644 packages/prisma/migrations/20250617060130_add_queued_response_table/migration.sql diff --git a/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts b/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts new file mode 100644 index 0000000000..006c93b045 --- /dev/null +++ b/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from "vitest"; + +import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; +import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore"; +import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; + +import { queuedResponseHandler } from "../route"; + +vi.mock("@calcom/lib/server/repository/formResponse"); +vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm"); +vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore"); +vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils"); + +const mockQueuedFormResponse = { + id: "1", + formId: "mock-form-id", + form: { + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + createdAt: new Date(), + updatedAt: new Date(), + fields: [], + routes: [], + userId: 1, + user: { + id: 1, + email: "test@example.com", + }, + team: null, + teamId: null, + position: 1, + updatedById: null, + settings: {}, + disabled: false, + }, + chosenRouteId: "mock-chosen-route-id", + createdAt: new Date(), + updatedAt: new Date(), + response: {}, + actualResponseId: 1, +}; + +describe("queuedResponseHandler", () => { + it("should process a queued form response", async () => { + vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue( + mockQueuedFormResponse + ); + + vi.mocked(getSerializableForm).mockResolvedValue({ + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + fields: [], + routes: [], + userId: 1, + teamId: null, + position: 1, + updatedById: null, + disabled: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + settings: {}, + } as unknown as Awaited>); + + vi.mocked(getResponseToStore).mockResolvedValue({ + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + fields: [], + routes: [], + user: { + id: 1, + email: "test@example.com", + }, + team: null, + } as unknown as Awaited>); + + vi.mocked(onSubmissionOfFormResponse).mockResolvedValue({ + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + fields: [], + routes: [], + user: { + id: 1, + email: "test@example.com", + }, + team: null, + } as unknown as Awaited>); + + vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue({ + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + fields: [], + routes: [], + user: { + id: 1, + email: "test@example.com", + }, + team: null, + chosenRouteId: "mock-chosen-route-id", + } as unknown as Awaited>); + + const response = await queuedResponseHandler({ + queuedFormResponseId: "1", + params: {}, + }); + expect(response).toEqual({ + formResponseId: "mock-form-id", + message: "Processed", + }); + }); + + it("if no queued form response is found, should return early", async () => { + vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null); + const response = await queuedResponseHandler({ + queuedFormResponseId: "1", + params: {}, + }); + expect(response).toEqual({ + formResponseId: null, + message: "Already processed", + }); + }); + + it("should throw if form has no fields", async () => { + vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue( + mockQueuedFormResponse + ); + vi.mocked(getSerializableForm).mockResolvedValue({ + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + fields: undefined, + routes: [], + user: { + id: 1, + email: "test@example.com", + }, + team: null, + } as unknown as Awaited>); + await expect( + queuedResponseHandler({ + queuedFormResponseId: "1", + params: {}, + }) + ).rejects.toThrow("Form has no fields"); + }); +}); diff --git a/apps/web/app/api/routing-forms/queued-response/route.ts b/apps/web/app/api/routing-forms/queued-response/route.ts new file mode 100644 index 0000000000..47a889beca --- /dev/null +++ b/apps/web/app/api/routing-forms/queued-response/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { z, ZodError } from "zod"; + +import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; +import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore"; +import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; + +import { defaultResponderForAppDir } from "../../defaultResponderForAppDir"; + +const queuedResponseSchema = z.object({ + queuedFormResponseId: z.string(), + params: z.record(z.string(), z.string().or(z.array(z.string()))), +}); + +export const queuedResponseHandler = async ({ + queuedFormResponseId, + params, +}: { + queuedFormResponseId: string; + params: Record; +}) => { + // Get the queued response + const queuedFormResponse = await RoutingFormResponseRepository.getQueuedFormResponseFromId( + queuedFormResponseId + ); + + if (!queuedFormResponse) { + return { + formResponseId: null, + message: "Already processed", + }; + } + + const serializableForm = await getSerializableForm({ + form: queuedFormResponse.form, + }); + + if (!serializableForm.fields) { + throw new Error("Form has no fields"); + } + + const response = getResponseToStore({ + formFields: serializableForm.fields, + fieldsResponses: params, + }); + + const formResponse = await RoutingFormResponseRepository.recordFormResponse({ + formId: queuedFormResponse.formId, + queuedFormResponseId: queuedFormResponse.id, + // We record new response here as that might be different from the queued response depending on if the user changed something in b/w before clicking CTA and that something wasn't prerendered + response, + // We use the queuedFormResponse's chosenRouteId because that is what decided routed team members + chosenRouteId: queuedFormResponse.chosenRouteId, + }); + + const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId); + await onSubmissionOfFormResponse({ + form: { + ...queuedFormResponse.form, + ...serializableForm, + }, + formResponseInDb: formResponse, + chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null, + }); + + return { + formResponseId: formResponse.id, + message: "Processed", + }; +}; + +export const handler = async (req: NextRequest) => { + try { + const body = await req.json(); + const { params, queuedFormResponseId } = queuedResponseSchema.parse(body); + const result = await queuedResponseHandler({ + queuedFormResponseId, + params, + }); + + return NextResponse.json({ status: "success", data: result }); + } catch (error) { + if (error instanceof ZodError) { + logger.error("Invalid input", safeStringify(error)); + return NextResponse.json( + { + status: "error", + message: "Invalid input", + }, + { status: 400 } + ); + } + + logger.error("Error in queuedResponseHandler", safeStringify(error)); + + return NextResponse.json( + { + status: "error", + message: "Internal server error", + }, + { status: 500 } + ); + } +}; + +export const POST = defaultResponderForAppDir(handler); diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts new file mode 100644 index 0000000000..15dee2b9f3 --- /dev/null +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts @@ -0,0 +1,72 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { App_RoutingForms_Form } from "@calcom/prisma/client"; +import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import { onFormSubmission } from "../trpc/utils"; +import type { FormResponse, SerializableForm } from "../types/types"; + +export type TargetRoutingFormForResponse = SerializableForm< + App_RoutingForms_Form & { + user: { + id: number; + email: string; + }; + team: { + parentId: number | null; + } | null; + } +>; + +/** + * A wrapper over onFormSubmission that handles building the data needed for onFormSubmission + */ +export const onSubmissionOfFormResponse = async ({ + form, + formResponseInDb, + chosenRouteAction, +}: { + form: TargetRoutingFormForResponse; + formResponseInDb: { id: number; response: Prisma.JsonValue }; + chosenRouteAction: { + type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + value: string; + } | null; +}) => { + if (!form.fields) { + // There is no point in submitting a form that doesn't have fields defined + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + const settings = RoutingFormSettings.parse(form.settings); + let userWithEmails: string[] = []; + + if (form.teamId && (settings?.sendToAll || settings?.sendUpdatesTo?.length)) { + const whereClause: Prisma.MembershipWhereInput = { teamId: form.teamId }; + if (!settings?.sendToAll) { + whereClause.userId = { in: settings.sendUpdatesTo }; + } + const userEmails = await prisma.membership.findMany({ + where: whereClause, + select: { + user: { + select: { + email: true, + }, + }, + }, + }); + userWithEmails = userEmails.map((userEmail) => userEmail.user.email); + } + + await onFormSubmission( + { ...form, fields: form.fields, userWithEmails }, + formResponseInDb.response as FormResponse, + formResponseInDb.id, + chosenRouteAction ?? undefined + ); +}; diff --git a/packages/app-store/routing-forms/lib/getResponseToStore.ts b/packages/app-store/routing-forms/lib/getResponseToStore.ts new file mode 100644 index 0000000000..26fc72bfed --- /dev/null +++ b/packages/app-store/routing-forms/lib/getResponseToStore.ts @@ -0,0 +1,26 @@ +import type { z } from "zod"; + +import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier"; +import { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse"; +import type { FormResponse } from "@calcom/app-store/routing-forms/types/types"; + +import type { zodFields } from "../zod"; + +export const getResponseToStore = ({ + formFields, + fieldsResponses, +}: { + formFields: NonNullable>; + fieldsResponses: Record; +}) => { + const response: FormResponse = {}; + formFields.forEach((field) => { + const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || ""; + + response[field.id] = { + label: field.label, + value: getFieldResponseForJsonLogic({ field, value: fieldResponse }), + }; + }); + return response; +}; diff --git a/packages/app-store/routing-forms/lib/handleResponse.ts b/packages/app-store/routing-forms/lib/handleResponse.ts index a3845432c1..6f8df578d6 100644 --- a/packages/app-store/routing-forms/lib/handleResponse.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.ts @@ -6,29 +6,14 @@ import logger from "@calcom/lib/logger"; import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; -import { prisma } from "@calcom/prisma"; -import type { App_RoutingForms_Form } from "@calcom/prisma/client"; -import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema"; import { TRPCError } from "@trpc/server"; import isRouter from "../lib/isRouter"; -import { onFormSubmission } from "../trpc/utils"; -import type { FormResponse, SerializableForm } from "../types/types"; import routerGetCrmContactOwnerEmail from "./crmRouting/routerGetCrmContactOwnerEmail"; - -export type Form = SerializableForm< - App_RoutingForms_Form & { - user: { - id: number; - email: string; - }; - team: { - parentId: number | null; - } | null; - } ->; +import { onSubmissionOfFormResponse, type TargetRoutingFormForResponse } from "./formSubmissionUtils"; const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] }); @@ -39,12 +24,14 @@ const _handleResponse = async ({ // formFillerId, chosenRouteId, isPreview, + queueFormResponse, }: { response: z.infer["response"]; - form: Form; + form: TargetRoutingFormForResponse; formFillerId: string; chosenRouteId: string | null; isPreview: boolean; + queueFormResponse?: boolean; }) => { try { if (!form.fields) { @@ -99,26 +86,6 @@ const _handleResponse = async ({ }); } - const settings = RoutingFormSettings.parse(form.settings); - let userWithEmails: string[] = []; - if (form.teamId && (settings?.sendToAll || settings?.sendUpdatesTo?.length)) { - const whereClause: Prisma.MembershipWhereInput = { teamId: form.teamId }; - if (!settings?.sendToAll) { - whereClause.userId = { in: settings.sendUpdatesTo }; - } - const userEmails = await prisma.membership.findMany({ - where: whereClause, - select: { - user: { - select: { - email: true, - }, - }, - }, - }); - userWithEmails = userEmails.map((userEmail) => userEmail.user.email); - } - const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); let teamMemberIdsMatchingAttributeLogic: number[] | null = null; let crmContactOwnerEmail: string | null = null; @@ -185,25 +152,29 @@ const _handleResponse = async ({ } else { // It currently happens for a Router route. Such a route id isn't present in the form.routes } - - let dbFormResponse; + let dbFormResponse, queuedFormResponse; if (!isPreview) { - dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ - data: { - // TODO: Why do we not save formFillerId available in the input? - // formFillerId, + if (queueFormResponse) { + queuedFormResponse = await RoutingFormResponseRepository.recordQueuedFormResponse({ formId: form.id, - response: response, + response, chosenRouteId, - }, - }); + }); + dbFormResponse = null; + } else { + dbFormResponse = await RoutingFormResponseRepository.recordFormResponse({ + formId: form.id, + response, + chosenRouteId, + }); + queuedFormResponse = null; - await onFormSubmission( - { ...serializableFormWithFields, userWithEmails }, - dbFormResponse.response as FormResponse, - dbFormResponse.id, - chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : undefined) : undefined - ); + await onSubmissionOfFormResponse({ + form: serializableFormWithFields, + formResponseInDb: dbFormResponse, + chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null, + }); + } } else { moduleLogger.debug("Dry run mode - Form response not stored and also webhooks and emails not sent"); // Create a mock response for dry run @@ -216,10 +187,10 @@ const _handleResponse = async ({ updatedAt: new Date(), }; } - return { isPreview: !!isPreview, formResponse: dbFormResponse, + queuedFormResponse, teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, crmContactOwnerEmail, crmContactOwnerRecordType, diff --git a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx index 0b81f99a78..e0d21e9390 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -101,6 +101,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) { const { teamMembersMatchingAttributeLogic, formResponse, + queuedFormResponse, attributeRoutingConfig, crmContactOwnerEmail, crmContactOwnerRecordType, @@ -116,7 +117,8 @@ function RoutingForm({ form, profile, ...restProps }: Props) { } const allURLSearchParams = getUrlSearchParamsToForward({ formResponse: chosenRouteWithFormResponse.response, - formResponseId: formResponse.id, + formResponseId: formResponse?.id ?? null, + queuedFormResponseId: queuedFormResponse?.id ?? null, fields, searchParams: new URLSearchParams(window.location.search), teamMembersMatchingAttributeLogic, diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts index ee1727589b..131df8bcd3 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts @@ -66,6 +66,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); @@ -100,6 +101,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); @@ -151,6 +153,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); @@ -202,6 +205,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); @@ -229,6 +233,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); @@ -272,6 +277,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: { skipContactOwner: true, }, @@ -317,6 +323,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: [1, 2], formResponseId: 1, + queuedFormResponseId: null, attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts index 29ae84668b..01fe01a11a 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts @@ -21,6 +21,7 @@ type GetUrlSearchParamsToForwardOptions = { >[]; searchParams: URLSearchParams; formResponseId: number | null; + queuedFormResponseId: string | null; teamMembersMatchingAttributeLogic: number[] | null; attributeRoutingConfig: AttributeRoutingConfig | null; reroutingFormResponses?: FormResponseValueOnly; @@ -37,6 +38,7 @@ export function getUrlSearchParamsToForward({ searchParams, teamMembersMatchingAttributeLogic, formResponseId, + queuedFormResponseId, attributeRoutingConfig, reroutingFormResponses, teamId, @@ -122,7 +124,8 @@ export function getUrlSearchParamsToForward({ ...(teamMembersMatchingAttributeLogic ? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") } : null), - [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId), + ...(formResponseId ? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) } : null), + ...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null), ...attributeRoutingConfigParams, ...(reroutingFormResponses ? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) } @@ -151,7 +154,7 @@ export function getUrlSearchParamsToForwardForReroute({ attributeRoutingConfig, rescheduleUid, reroutingFormResponses, -}: GetUrlSearchParamsToForwardOptions & { +}: Omit & { rescheduleUid: string; reroutingFormResponses: FormResponseValueOnly; }) { @@ -160,6 +163,8 @@ export function getUrlSearchParamsToForwardForReroute({ return getUrlSearchParamsToForward({ formResponse, formResponseId, + // Queued form response id is not available in rerouting + queuedFormResponseId: null, fields, searchParams, teamMembersMatchingAttributeLogic, @@ -187,6 +192,8 @@ export function getUrlSearchParamsToForwardForTestPreview({ teamMembersMatchingAttributeLogic, // There is no form response being stored in test preview formResponseId: null, + // Queued form response id is not available in test preview + queuedFormResponseId: null, searchParams, }); } diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 5ea011a562..0183dedab8 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -1,13 +1,8 @@ Embed Playground - - + + +
- Routing Form Prerender Demo +
+ Routing Form Prerender Team Booking Page - Demo

1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)

-

2. Clicking on submit will open the prerendered/being prerendered iframe in the modal

+

2. Clicking on submit will open the prerendered/being prerendered iframe in the modal, sending fresh request for headless router, followed by fetching the slots

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)

-

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

+

4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as slotsStaleTimeMs threshold is not breached). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error

@@ -143,33 +163,80 @@ });
-
- Routing Form Without Prerender Demo - - - - - - - + +
+
+
+ + +
+
+

+
- + diff --git a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts index 87dc657b60..f2598d6b44 100644 --- a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts +++ b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { embedStore, getEmbedBookerState } from "../embed-iframe"; - // Test helper functions type fakeCurrentDocumentUrlParams = { origin?: string; @@ -25,25 +23,25 @@ function mockDocumentUrl(url: URL | string) { return vi.spyOn(document, "URL", "get").mockReturnValue(url.toString()); } -function createSearchParams(params: Record) { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - searchParams.set(key, value); - }); - return searchParams; +afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + vi.useRealTimers(); +}); + +function nextTick() { + vi.advanceTimersByTime(100); } -describe("embedStore.router.ensureQueryParamsInUrl", () => { - // Mock window.history and URL - +describe("embedStore.router.ensureQueryParamsInUrl", async () => { + let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore; const originalHistory = window.history; const originalURL = window.URL; - function nextTick() { - vi.advanceTimersByTime(100); - } + beforeEach(async () => { + // Mock window.history and URL + ({ embedStore } = await import("../embed-iframe/lib/embedStore")); - beforeEach(() => { vi.useFakeTimers(); // Mock requestAnimationFrame and cancelAnimationFrame window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { @@ -75,7 +73,6 @@ describe("embedStore.router.ensureQueryParamsInUrl", () => { it("should add missing parameters to URL", async () => { // Setup fakeCurrentDocumentUrl(); - // Execute const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ toBeThereParams: { @@ -163,7 +160,13 @@ describe("embedStore.router.ensureQueryParamsInUrl", () => { }); }); -describe("getEmbedBookerState", () => { +describe("getEmbedBookerState", async () => { + let getEmbedBookerState: typeof import("../embed-iframe").getEmbedBookerState; + beforeEach(async () => { + fakeCurrentDocumentUrl(); + ({ getEmbedBookerState } = await import("../embed-iframe")); + }); + it("should return 'initializing' when bookerState is 'loading'", () => { const result = getEmbedBookerState({ bookerState: "loading", @@ -242,3 +245,138 @@ describe("getEmbedBookerState", () => { expect(result).toBe("slotsPending"); }); }); + +describe("methods", async () => { + let methods: typeof import("../embed-iframe").methods; + let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore; + let isLinkReadyMock: ReturnType | undefined; + let isBookerReadyMock: ReturnType | undefined; + let ensureQueryParamsInUrlMock: ReturnType | undefined; + let recordResponseIfQueuedMock: ReturnType | undefined; + beforeEach(async () => { + vi.useRealTimers(); + fakeCurrentDocumentUrl(); + vi.doMock("../embed-iframe/lib/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isLinkReady: isLinkReadyMock, + isBookerReady: isBookerReadyMock, + recordResponseIfQueued: recordResponseIfQueuedMock, + }; + }); + vi.doMock("../embed-iframe/lib/embedStore", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + embedStore: { + ...actual.embedStore, + router: { + ensureQueryParamsInUrl: ensureQueryParamsInUrlMock, + }, + }, + }; + }); + isLinkReadyMock = vi.fn(); + isBookerReadyMock = vi.fn(); + ensureQueryParamsInUrlMock = vi.fn().mockImplementation(() => { + console.log("Fake ensureQueryParamsInUrl called"); + return { + stopEnsuringQueryParamsInUrl: vi.fn(), + }; + }); + recordResponseIfQueuedMock = vi.fn().mockResolvedValue(1); + ({ methods } = await import("../embed-iframe")); + ({ embedStore } = await import("../embed-iframe/lib/embedStore")); + }); + + describe("methods.connect", async () => { + it("should ensure that 'cal.embed.connectVersion' is incremented in query params", async () => { + embedStore.renderState = "completed"; + const currentConnectVersion = 1; + embedStore.connectVersion = currentConnectVersion; + fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } }); + isLinkReadyMock?.mockReturnValue(true); + isBookerReadyMock?.mockReturnValue(true); + await methods.connect({ + config: {}, + params: {}, + }); + expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({ + toBeThereParams: { + "cal.embed.connectVersion": (currentConnectVersion + 1).toString(), + }, + toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], + }); + }); + + it("should ensure that 'cal.embed.connectVersion' is not incremented in query params when 'cal.embed.noSlotsFetchOnConnect' is true", async () => { + embedStore.renderState = "completed"; + const currentConnectVersion = 1; + embedStore.connectVersion = currentConnectVersion; + fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } }); + isLinkReadyMock?.mockReturnValue(true); + isBookerReadyMock?.mockReturnValue(true); + await methods.connect({ + config: { + "cal.embed.noSlotsFetchOnConnect": "true", + }, + params: {}, + }); + expect(embedStore.router.ensureQueryParamsInUrl).toHaveBeenCalledWith({ + toBeThereParams: { + "cal.embed.connectVersion": currentConnectVersion.toString(), + }, + toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], + }); + }); + + it("should set/update 'cal.routingFormResponseId' if the current iframe has 'cal.queuedFormResponse' in the query params", async () => { + embedStore.renderState = "completed"; + const currentConnectVersion = 1; + embedStore.connectVersion = currentConnectVersion; + fakeCurrentDocumentUrl({ + params: { + "cal.queuedFormResponse": "true", + "cal.routingFormResponseId": "1", + "cal.embed.connectVersion": currentConnectVersion.toString(), + }, + }); + const convertedRoutingFormResponseId = 101; + recordResponseIfQueuedMock?.mockResolvedValue(convertedRoutingFormResponseId); + isLinkReadyMock?.mockReturnValue(true); + isBookerReadyMock?.mockReturnValue(true); + await methods.connect({ + config: {}, + params: {}, + }); + + expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({ + toBeThereParams: { + "cal.embed.connectVersion": (currentConnectVersion + 1).toString(), + "cal.routingFormResponseId": convertedRoutingFormResponseId.toString(), + }, + toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"], + }); + }); + }); + + describe("methods.parentKnowsIframeReady", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should set renderState to 'completed' on link ready", () => { + isLinkReadyMock?.mockReturnValue(true); + methods.parentKnowsIframeReady({}); + expect(embedStore.renderState).not.toBe("completed"); + nextTick(); + expect(embedStore.renderState).toBe("completed"); + }); + }); +}); diff --git a/packages/embeds/embed-core/src/constants.ts b/packages/embeds/embed-core/src/constants.ts index 6abf73b709..880df0c5a0 100644 --- a/packages/embeds/embed-core/src/constants.ts +++ b/packages/embeds/embed-core/src/constants.ts @@ -1,7 +1,9 @@ // 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 +// Configurable via options.slotsStaleTimeMs. We could keep it at a lower end as the impact would be refetch of availability only. +export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 60 * 1000; // 1 minute +// Keep it high by default to avoid impact on loading time as it would cause a full reload of the iframe user can configure it via options.iframeForceReloadThresholdMs +// Also full reload means that response would be resubmitted. +export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) { throw new Error( diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 58f8bf6603..0893358764 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -3,9 +3,10 @@ import { useEffect, useRef, useState, useCallback } from "react"; import type { Message } from "./embed"; +import { embedStore, EMBED_IFRAME_STATE } from "./embed-iframe/lib/embedStore"; +import { runAsap, isBookerReady, isLinkReady, recordResponseIfQueued } from "./embed-iframe/lib/utils"; import { sdkActionManager } from "./sdk-event"; import type { - EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, @@ -13,9 +14,10 @@ import type { EmbedBookerState, SlotsQuery, PrefillAndIframeAttrsConfig, + SetStyles, + setNonStylesConfig, } 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"; @@ -27,16 +29,12 @@ const eventsAllowedInPrerendering = [ // so that iframe height is adjusted according to the content, and iframe is ready to be shown when needed "__dimensionChanged", + // When this event is fired, the iframe is still in prerender state but is going to be moved out of prerender state + "__connectInitiated", + // For other events, we should consider introducing prerender specific events and not reuse existing events ]; -type SetStyles = React.Dispatch>; -type setNonStylesConfig = React.Dispatch>; -const enum EMBED_IFRAME_STATE { - NOT_INITIALIZED, - INITIALIZED, -} - declare global { interface Window { CalEmbed: { @@ -49,110 +47,6 @@ declare global { } } -/** - * This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost. - */ -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: { - /** - * 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; - toRemoveParams: string[]; - }) { - let stopUpdating = false; - function updateIfNeeded() { - if (stopUpdating) { - return { hasChanged: false }; - } - const currentUrl = new URL(document.URL); - let hasChanged = false; - - // 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()); - } - runAsap(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); - }); - } - }, - }, - - state: EMBED_IFRAME_STATE.NOT_INITIALIZED, - // Store all embed styles here so that as and when new elements are mounted, styles can be applied to it. - styles: {} as EmbedStyles | undefined, - nonStyles: {} as EmbedNonStylesConfig | undefined, - namespace: null as string | null, - embedType: undefined as undefined | null | string, - // Store all React State setters here. - reactStylesStateSetters: {} as Record, - reactNonStylesStateSetters: {} as Record, - // Embed can show itself only after this is set to true - parentInformedAboutContentHeight: false, - windowLoadEventFired: false, - setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined, - theme: undefined as UiConfig["theme"], - uiConfig: undefined as Omit | undefined, - /** - * We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components. - */ - setUiConfig: [] as ((arg0: UiConfig) => void)[], -}; - let isSafariBrowser = false; const isBrowser = typeof window !== "undefined"; @@ -166,11 +60,6 @@ if (isBrowser) { } } -function runAsap(fn: (...arg: unknown[]) => void) { - // We don't use rAF because it runs slower in Safari plus doesn't run if the iframe is hidden sometimes - return setTimeout(fn, 50); -} - function log(...args: unknown[]) { if (isBrowser) { const namespace = getNamespace(); @@ -366,31 +255,6 @@ 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 === "slotsDone"; -} - -function isBookerPage() { - return !!window._embedBookerState; -} - export const useIsEmbed = (embedSsr?: boolean) => { const [isEmbed, setIsEmbed] = useState(embedSsr); useEffect(() => { @@ -441,8 +305,44 @@ function showPageAsNonEmbed() { } } +async function ensureRoutingFormResponseIdInUrl({ + newlyRecordedResponseId, + toBeThereParams, + toRemoveParams, +}: { + newlyRecordedResponseId: number; + toBeThereParams: Record; + toRemoveParams: string[]; +}) { + // Update routingFormResponseId in url only after connect is completed, to keep things simple + // Adding cal.routingFormResponseId in query param later shouldn't change anything in UI plus no slot request would go again due ot this. + + const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams: { + ...toBeThereParams, + "cal.routingFormResponseId": newlyRecordedResponseId.toString(), + }, + toRemoveParams, + }); + // Immediately stop ensuring query params in url as the page is already ready + // We could think about doing it after some time if needed later. + stopEnsuringQueryParamsInUrl(); +} + +async function waitForRenderStateToBeCompleted() { + return new Promise((resolve) => { + (function tryToConnect() { + if (embedStore.renderState !== "completed") { + runAsap(tryToConnect); + return; + } + resolve(); + })(); + }); +} + // It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"}) -const methods = { +export const methods = { ui: function style(uiConfig: UiConfig) { // TODO: Create automatic logger for all methods. Useful for debugging. log("Method: ui called", uiConfig); @@ -490,27 +390,24 @@ const methods = { // eslint-disable-next-line @typescript-eslint/no-unused-vars parentKnowsIframeReady: (_unused: unknown) => { log("Method: `parentKnowsIframeReady` called"); - + // No UI change should happen in sight. Let the parent height adjust and in next cycle show it. + // Embed background must still remain transparent runAsap(function tryInformingLinkReady() { - if (!isLinkReady()) { + if (!isLinkReady({ embedStore })) { runAsap(tryInformingLinkReady); return; } - // No UI change should happen in sight. Let the parent height adjust and in next cycle show it. - // Embed background must still remain transparent makeBodyVisible(); - if (isPrerendering()) { - log("prerenderState is 'completed'"); - embedStore.prerenderState = "completed"; - } + log("renderState is 'completed'"); + embedStore.renderState = "completed"; sdkActionManager?.fire("linkReady", {}); }); }, /** * Connects new config to prerendered page */ - connect: function connect({ + connect: async function connect({ config, params, }: { @@ -520,12 +417,23 @@ const methods = { // We can't accept URLSearchParams as it isn't cloneable and thus postMessage doesn't support it params: Record; }) { + sdkActionManager?.fire("__connectInitiated", {}); log("Method: connect, requested with params", { config, params }); - const { iframeAttrs: _1, ...queryParamsFromConfig } = config; - const connectVersion = (embedStore.connectVersion = embedStore.connectVersion + 1); + const { + iframeAttrs: _1, + "cal.embed.noSlotsFetchOnConnect": noSlotsFetchOnConnect, + ...queryParamsFromConfig + } = config; // We reset it to allow informing parent again through `__dimensionChanged` event about possibly updated dimensions with changes in config embedStore.parentInformedAboutContentHeight = false; + if (noSlotsFetchOnConnect !== "true") { + log("Method: connect, noSlotsFetchOnConnect is false. Requesting slots re-fetch"); + // Incrementing the version forces the slots call to be made again + embedStore.connectVersion = embedStore.connectVersion + 1; + } + + const connectVersion = embedStore.connectVersion; // 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 = { @@ -535,19 +443,26 @@ const methods = { "cal.embed.connectVersion": connectVersion.toString(), }; - (function tryToConnect() { - if (embedStore.prerenderState !== "completed") { - runAsap(tryToConnect); - return; - } + const toRemoveParams = ["preload", "prerender", "cal.skipSlotsFetch"]; + await waitForRenderStateToBeCompleted(); - 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"], - }); - })(); + log("Method: connect, renderState is completed. Connecting"); + await connectPreloadedEmbed({ + // We know after removing iframeAttrs, that it is of this type + toBeThereParams, + toRemoveParams, + }); + + // We now record the response to routingFormResponse and connect that with queuedResponse, as the user actually opened the modal which is confirmed by this connect method call + const newlyRecordedResponseId = await recordResponseIfQueued(params); + if (!newlyRecordedResponseId) { + return; + } + await ensureRoutingFormResponseIdInUrl({ + newlyRecordedResponseId, + toBeThereParams, + toRemoveParams, + }); }, }; @@ -723,9 +638,7 @@ function initializeAndSetupEmbed() { isPrerendering: isPrerendering(), }); - if (isPrerendering()) { - embedStore.prerenderState = "inProgress"; - } + embedStore.renderState = "inProgress"; // Only NOT_INITIALIZED -> INITIALIZED transition is allowed if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) { @@ -766,7 +679,7 @@ 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({ +async function connectPreloadedEmbed({ toBeThereParams, toRemoveParams, }: { @@ -782,7 +695,7 @@ function connectPreloadedEmbed({ if (isBookerReady() && hasChanged) { // Give some time for react to update state that might lead booker to go to slotsLoading state - waitForFrames = 5; + waitForFrames = 2; } // 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 @@ -790,17 +703,25 @@ function connectPreloadedEmbed({ // 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", {}); + await new Promise((resolve) => { + runAsap(function tryToFireLinkReady() { + if (!isLinkReady({ embedStore }) || 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("__connectCompleted", {}); + sdkActionManager?.fire("linkReady", {}); + resolve(); + }); }); + + return { + stopEnsuringQueryParamsInUrl, + }; } const isPrerendering = () => { diff --git a/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts b/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts new file mode 100644 index 0000000000..003db7caf4 --- /dev/null +++ b/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts @@ -0,0 +1,118 @@ +import type { + EmbedThemeConfig, + UiConfig, + EmbedNonStylesConfig, + EmbedStyles, + SetStyles, + setNonStylesConfig, +} from "../../types"; +import { isParamValuePresentInUrlSearchParams } from "../../utils"; +import { runAsap } from "./utils"; + +export const enum EMBED_IFRAME_STATE { + NOT_INITIALIZED, + INITIALIZED, +} +/** + * This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost. + */ +export const embedStore = { + connectVersion: 0 as number, + /** + * Tracks whether the iframe is fully rendered or in progress of rendering. + * In case of prerendering as well as non-prerendering, it would be "completed" after the iframe is fully rendered. + */ + renderState: 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: { + /** + * 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; + toRemoveParams: string[]; + }) { + let stopUpdating = false; + function updateIfNeeded() { + if (stopUpdating) { + return { hasChanged: false }; + } + const currentUrl = new URL(document.URL); + let hasChanged = false; + + // 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()); + } + runAsap(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); + }); + } + }, + }, + + state: EMBED_IFRAME_STATE.NOT_INITIALIZED, + // Store all embed styles here so that as and when new elements are mounted, styles can be applied to it. + styles: {} as EmbedStyles | undefined, + nonStyles: {} as EmbedNonStylesConfig | undefined, + namespace: null as string | null, + embedType: undefined as undefined | null | string, + // Store all React State setters here. + reactStylesStateSetters: {} as Record, + reactNonStylesStateSetters: {} as Record, + // Embed can show itself only after this is set to true + parentInformedAboutContentHeight: false, + windowLoadEventFired: false, + setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined, + theme: undefined as UiConfig["theme"], + uiConfig: undefined as Omit | undefined, + /** + * We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components. + */ + setUiConfig: [] as ((arg0: UiConfig) => void)[], +}; diff --git a/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts b/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts new file mode 100644 index 0000000000..9994123abf --- /dev/null +++ b/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts @@ -0,0 +1,58 @@ +export function runAsap(fn: (...arg: unknown[]) => void) { + // We don't use rAF because it runs slower in Safari plus doesn't run if the iframe is hidden sometimes + return setTimeout(fn, 50); +} + +export function isBookerPage() { + return !!window._embedBookerState; +} + +export function isBookerReady() { + return window._embedBookerState === "slotsDone"; +} + +/** + * 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 + */ +export function isLinkReady({ embedStore }: { embedStore: typeof import("./embedStore").embedStore }) { + 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; +} + +/** + * Moves the queuedFormResponse to the routingFormResponse record to mark it as an actual response now. + */ +export const recordResponseIfQueued = async (params: Record) => { + const url = new URL(document.URL); + let routingFormResponseId: number | null = null; + const queuedFormResponseIdParam = url.searchParams.get("cal.queuedFormResponseId"); + const queuedFormResponseId = queuedFormResponseIdParam; + if (!queuedFormResponseId) { + return null; + } + // form is formId and isn't acutal Form data + const { form: _1, ...actualFormData } = params; + const res = await fetch(`/api/routing-forms/queued-response`, { + method: "POST", + body: JSON.stringify({ queuedFormResponseId, params: actualFormData }), + }); + if (!res.ok) { + return null; + } + const response = await res.json(); + const formResponseId = response.data.formResponseId; + if (formResponseId) { + // Now we have the actual routingFormResponseId. + routingFormResponseId = formResponseId; + } + return routingFormResponseId; +}; diff --git a/packages/embeds/embed-core/src/embed.test.ts b/packages/embeds/embed-core/src/embed.test.ts index 0df4686284..7ae49c0a17 100644 --- a/packages/embeds/embed-core/src/embed.test.ts +++ b/packages/embeds/embed-core/src/embed.test.ts @@ -2,6 +2,10 @@ import "../test/__mocks__/windowMatchMedia"; import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest"; +import { + EMBED_MODAL_IFRAME_SLOT_STALE_TIME, + EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS, +} from "./constants"; import { submitResponseAndGetRoutingResult } from "./utils"; vi.mock("./tailwindCss", () => ({ @@ -339,6 +343,10 @@ describe("Cal", () => { layout: modalArg.config.layout, }); + // Verify that the embedRenderStartTime and embedConfig are set, which are used by getNextActionForModal to decide if the modal should be reused or not + expect(calInstance.embedRenderStartTime).toBeGreaterThan(0); + expect(calInstance.embedConfig).toBeDefined(); + expectIframeToHaveMatchingUrl({ element: modalBox, expectedIframeUrlObject: { @@ -347,6 +355,44 @@ describe("Cal", () => { ...modalArg.config, prerender: "true", embedType: "modal", + "cal.skipSlotsFetch": "true", + }), + origin: null, + }, + }); + }); + + it("should create modal having iframe with queueFormResponse=true param when prerendering headless router", () => { + const modalArg = { + ...baseModalArgs, + calLink: "router?form=123&email=john@example.com", + config: { + ...baseModalArgs.config, + "cal.embed.pageType": "doesntmatter", + }, + calOrigin: null, + __prerender: true, + }; + calInstance.api.modal(modalArg); + + const modalBox = expectCalModalBoxToBeInDocument({ + state: "prerendering", + pageType: modalArg.config["cal.embed.pageType"], + theme: modalArg.config.theme, + layout: modalArg.config.layout, + }); + + expectIframeToHaveMatchingUrl({ + element: modalBox, + expectedIframeUrlObject: { + pathname: `${new URL(modalArg.calLink, "http://example.com").pathname}`, + searchParams: new URLSearchParams({ + ...modalArg.config, + prerender: "true", + embedType: "modal", + "cal.queueFormResponse": "true", + email: "john@example.com", + form: "123", }), origin: null, }, @@ -433,7 +479,9 @@ describe("Cal", () => { expect(calInstance.doInIframe).toHaveBeenCalledWith({ method: "connect", arg: { - config: expectedConfigAfterPrefilling, + config: { + ...expectedConfigAfterPrefilling, + }, params: {}, }, }); @@ -543,7 +591,7 @@ describe("Cal", () => { pathname: `/${modalArg.calLink}`, searchParams: new URLSearchParams({ ...expectedConfig, - // It remains because internally we remove prerender=true in iframe + // It remains because internally we remove prerender=true in iframe - through connect flow prerender: "true", "cal.skipSlotsFetch": "true", }), @@ -554,7 +602,9 @@ describe("Cal", () => { expect(calInstance.doInIframe).toHaveBeenCalledWith({ method: "connect", arg: { - config: expectedConfigAfterPrefilling, + config: { + ...expectedConfigAfterPrefilling, + }, params: {}, }, }); @@ -587,7 +637,9 @@ describe("Cal", () => { }, expectedIframeUrlObject: { pathname: `/${modalArg.calLink}`, - searchParams: new URLSearchParams(expectedConfigAfterPrefilling), + searchParams: new URLSearchParams({ + ...expectedConfigAfterPrefilling, + }), origin: null, }, }); @@ -683,7 +735,9 @@ describe("Cal", () => { expect.objectContaining({ method: "connect", arg: { - config: configWithPrefilledValues, + config: { + ...configWithPrefilledValues, + }, params: {}, }, }) @@ -768,7 +822,9 @@ describe("Cal", () => { }, expectedIframeUrlObject: { pathname: headlessRouterRedirectUrlWithPathOnly, - searchParams: new URLSearchParams(configWithPrefilledValuesAndHeadlessRouterRedirectParams), + searchParams: new URLSearchParams({ + ...configWithPrefilledValuesAndHeadlessRouterRedirectParams, + }), origin: null, }, }); @@ -1062,7 +1118,7 @@ describe("Cal", () => { ...baseArgs, stateData: { ...baseArgs.stateData, - previousEmbedRenderStartTime: Date.now() - 1000000, // Much older timestamp + previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS - 1, // Much older timestamp embedRenderStartTime: Date.now(), }, }); @@ -1158,5 +1214,33 @@ describe("Cal", () => { const result = calInstance.getNextActionForModal(baseArgs); expect(result).toBe("noAction"); }); + + it("should return connect-no-slots-fetch when reuseFully is true and slots are not stale", () => { + const result = calInstance.getNextActionForModal({ + ...baseArgs, + stateData: { + ...baseArgs.stateData, + prerenderOptions: { + reuseFully: true, + }, + }, + }); + expect(result).toBe("connect-no-slots-fetch"); + }); + + it("should return connect when reuseFully is true but slots are stale", () => { + const result = calInstance.getNextActionForModal({ + ...baseArgs, + stateData: { + ...baseArgs.stateData, + prerenderOptions: { + reuseFully: true, + }, + previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1, + embedRenderStartTime: Date.now(), + }, + }); + expect(result).toBe("connect"); + }); }); }); diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 56c386483d..fd12b313f9 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -51,6 +51,21 @@ type CalConfig = { uiDebug?: boolean; }; +type ModalPrerenderOptions = { + slotsStaleTimeMs?: number; + iframeForceReloadThresholdMs?: number; + reuseFully?: boolean; +}; + +type ModalStateData = { + embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme; + previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null; + embedRenderStartTime: number; + previousEmbedRenderStartTime: number | null; + isConnectionInitiated: boolean; + prerenderOptions: ModalPrerenderOptions | null; +}; + type InitArgConfig = Partial & { origin?: string; }; @@ -309,7 +324,7 @@ export class Cal { config?: PrefillAndIframeAttrsConfig; calOrigin: string | null; }) { - log("Loading in iframe", calLink); + log("Loading in iframe", calLink, "with config", JSON.stringify(config)); iframe.dataset.calLink = calLink; const calConfig = this.getCalConfig(); const { iframeAttrs, ...queryParamsFromConfig } = config; @@ -522,13 +537,7 @@ export class Cal { }: { modal: { uid: string }; pathWithQueryToLoad: string; - stateData: { - embedConfig: PrefillAndIframeAttrsConfig; - previousEmbedConfig: PrefillAndIframeAttrsConfig | null; - isConnectionInitiated: boolean; - previousEmbedRenderStartTime: number | null; - embedRenderStartTime: number; - }; + stateData: ModalStateData; }) { const { embedConfig, @@ -536,6 +545,7 @@ export class Cal { isConnectionInitiated, previousEmbedRenderStartTime, embedRenderStartTime, + prerenderOptions, } = stateData; const calConfig = this.getCalConfig(); const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe(); @@ -570,43 +580,59 @@ export class Cal { ? embedRenderStartTime - previousEmbedRenderStartTime : 0; const crossedReloadThreshold = previousEmbedRenderStartTime - ? timeSinceLastRender > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS + ? timeSinceLastRender > + (prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) : false; const areSlotsStale = previousEmbedRenderStartTime - ? timeSinceLastRender > EMBED_MODAL_IFRAME_SLOT_STALE_TIME + ? timeSinceLastRender > (prerenderOptions?.slotsStaleTimeMs ?? 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"; + const getActionToTake = () => { + if (isResetNeeded) { + return "fullReload"; + } - log("Next Modal Action:", actionToTake, { - path: { - isSame: isSameCalLink, - urlToLoadPath, - lastLoadedPathInIframe, - }, - config: { - isSame: isSameConfig, - previousEmbedConfig, - embedConfig, - }, - queryParams: { - isSame: areSameQueryParams, - lastLoadedUrlInIframeObjectSearchParams, - urlToLoadObjectSearchParams, - }, - areSlotsStale, - crossedReloadThreshold, - isInFailedState, - isConnectionInitiated, - }); + if (prerenderOptions?.reuseFully && !areSlotsStale) { + return "connect-no-slots-fetch"; + } + + if (!isSameConfig || !areSameQueryParams || !isConnectionInitiated || areSlotsStale) { + return "connect"; + } + return "noAction"; + }; + + const actionToTake = getActionToTake(); + + log( + "Next Modal Action:", + { actionToTake, prerenderOptions }, + { + path: { + isSame: isSameCalLink, + urlToLoadPath, + lastLoadedPathInIframe, + }, + config: { + isSame: isSameConfig, + previousEmbedConfig, + embedConfig, + }, + queryParams: { + isSame: areSameQueryParams, + lastLoadedUrlInIframeObjectSearchParams, + urlToLoadObjectSearchParams, + }, + areSlotsStale, + crossedReloadThreshold, + isInFailedState, + isConnectionInitiated, + } + ); return actionToTake; @@ -665,13 +691,7 @@ export class Cal { }: { modal: { uid: string; element: Element; calOrigin: string | null }; calLinkUrlObject: URL; - stateData: { - embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme; - previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null; - embedRenderStartTime: number; - previousEmbedRenderStartTime: number | null; - isConnectionInitiated: boolean; - }; + stateData: ModalStateData; }) { const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal; const { embedConfig } = stateData; @@ -755,6 +775,7 @@ class CalApi { static initializedNamespaces = [] as string[]; modalUid?: string; prerenderedModalUid?: string; + prerenderOptions?: ModalPrerenderOptions; constructor(cal: Cal) { this.cal = cal; } @@ -946,12 +967,27 @@ class CalApi { config = {}, calOrigin, __prerender = false, + prerenderOptions = {}, }: { calLink: string; config?: PrefillAndIframeAttrsConfig; calOrigin?: string; __prerender?: boolean; + prerenderOptions?: ModalPrerenderOptions; }) { + const calConfig = this.cal.getCalConfig(); + // Clone so that it doesn't mutate the original config + config = { ...config }; + // calOrigin could have been passed as empty string by the user + calOrigin = calOrigin || calConfig.calOrigin; + const calLinkUrlObject = new URL(calLink, calOrigin); + const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false; + + if (__prerender && this.cal.modalBox) { + // If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want + this.cal.modalBox.remove(); + } + // `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"; @@ -959,15 +995,24 @@ class CalApi { const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid); const containerEl = document.body; - this.cal.isPrerendering = !!__prerender; - if (__prerender) { + // TODO: Make `reuseFully` a configurable param as well later. + // If someone's preloading the headless router path, we must reuse the iframe fully as is as Router would redirect to a Booking Page and that should be shown as is + this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath }; // 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"; + + // If we are prerendering a headless router path, we don't want to record the response immediately. + if (isHeadlessRouterPath) { + config["cal.queueFormResponse"] = "true"; + } + + if (!this.prerenderOptions?.reuseFully) { + // 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( @@ -978,22 +1023,17 @@ class CalApi { containerEl ); - const calConfig = this.cal.getCalConfig(); - // calOrigin could have been passed as empty string by the user - calOrigin = calOrigin || calConfig.calOrigin; + const previousEmbedRenderStartTime = this.cal.embedRenderStartTime; + const previousEmbedConfig = this.cal.embedConfig; const embedRenderStartTime = Date.now(); - const previousEmbedConfig = this.cal.embedConfig; - const previousEmbedRenderStartTime = this.cal.embedRenderStartTime; + this.cal.embedRenderStartTime = embedRenderStartTime; this.cal.embedConfig = configWithGuestKeyAndColorScheme; const existingModalEl = document.querySelector(`cal-modal-box[uid="${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, @@ -1001,8 +1041,11 @@ class CalApi { embedRenderStartTime, previousEmbedRenderStartTime, isConnectionInitiated, + prerenderOptions: this.prerenderOptions ?? null, }; - if (isHeadlessRouterPath) { + + // If we want to reuse fully then we shouldn't submit response again + if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) { // Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated existingModalEl.setAttribute("state", "loading"); @@ -1021,6 +1064,10 @@ class CalApi { }); if (actionToTake === "noAction") { + if (__prerender) { + // A prerender instruction can never show the modal + return; + } log(`Reopening modal without any other action needed ${uid}`); // Reopen the modal, nothing else to do existingModalEl.setAttribute("state", "reopened"); @@ -1039,11 +1086,18 @@ class CalApi { iframe: this.cal.iframe, config: configWithGuestKeyAndColorScheme, }); - } else if (actionToTake === "connect") { + } else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") { this.cal.doInIframe({ method: "connect", arg: { - config: configWithGuestKeyAndColorScheme, + config: { + ...configWithGuestKeyAndColorScheme, + ...(actionToTake === "connect-no-slots-fetch" + ? { + "cal.embed.noSlotsFetchOnConnect": "true", + } + : {}), + }, params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()), }, }); @@ -1052,7 +1106,6 @@ class CalApi { // We reach here in case of connect or fullReload this.modalUid = uid; - this.cal.embedRenderStartTime = embedRenderStartTime; return; } @@ -1165,6 +1218,8 @@ class CalApi { type?: "modal" | "floatingButton"; options?: { prerenderIframe?: boolean; + slotsStaleTimeMs?: number; + iframeForceReloadThresholdMs?: number; }; pageType?: EmbedPageType; calOrigin?: string; @@ -1198,7 +1253,8 @@ class CalApi { } const config = this.cal.getCalConfig(); - let prerenderIframe = options.prerenderIframe; + const { prerenderIframe: prerenderIframeOption, ...prerenderOptions } = options; + let prerenderIframe = prerenderIframeOption; if (type && prerenderIframe === undefined) { prerenderIframe = true; } @@ -1214,6 +1270,7 @@ class CalApi { calLink, calOrigin: calOrigin || config.calOrigin, __prerender: true, + prerenderOptions, ...(pageType ? { config: { "cal.embed.pageType": pageType } } : {}), }); } else { @@ -1225,22 +1282,50 @@ class CalApi { } } + /** + * Usecase - Prerender headless router path(i.e. `/router?form={FORM_ID}¶m1=value1&......`) + * + * - Loads the headless router path in iframe hidden behind the scenes + * - Stores the response in a temporary queue at backend. + * - Redirects to appropriate booking page based on the Routing Rules + * - Loads the availability as per the team members identified by Routing - Attribute Rules + * - At this point, the booking page is ready to be used but is hidden behind the scenes + * + * A call to `modal` instruction(which automatically happens when an element with `data-cal-link` attribute is clicked) would then show this prerendered booking page sending a request `queued-response` that records the field values now as a response and connect it to the queued-response + */ prerender({ calLink, type, pageType, calOrigin, + options = {}, }: { calLink: string; type: "modal" | "floatingButton"; pageType?: EmbedPageType; calOrigin?: string; + options?: { + /** + * Time in milliseconds after which the prerendered slots/availability should be considered stale and would be requested again when the modal is opened. This could slow down the booking page load by the time taken by slots loading request which shouldn't be too high + * + * Default value is 1 min + */ + slotsStaleTimeMs?: number; + /** + * Time in milliseconds after which the iframe would be forcefully reloaded when the modal is opened and thus booking page load could slow down drastically showing the skeleton loader in the meantime + * To avoid reaching this threshold, the user could prerender before this time is reached in usecases where prerender has been done long time ago. + * + * Default value is 15 mins + */ + iframeForceReloadThresholdMs?: number; + }; }) { this.preload({ calLink, type, pageType, calOrigin, + options, }); } diff --git a/packages/embeds/embed-core/src/sdk-action-manager.ts b/packages/embeds/embed-core/src/sdk-action-manager.ts index 97cb657a06..0add6c4ee9 100644 --- a/packages/embeds/embed-core/src/sdk-action-manager.ts +++ b/packages/embeds/embed-core/src/sdk-action-manager.ts @@ -22,6 +22,8 @@ export type EventDataMap = { }; }; linkReady: Record; + __connectInitiated: Record; + __connectCompleted: Record; bookingSuccessfulV2: { uid: string | undefined; title: string | undefined; diff --git a/packages/embeds/embed-core/src/types.ts b/packages/embeds/embed-core/src/types.ts index 0bbe3121f3..adb6340ff4 100644 --- a/packages/embeds/embed-core/src/types.ts +++ b/packages/embeds/embed-core/src/types.ts @@ -67,6 +67,8 @@ export type KnownConfig = { // Prefixing with cal.embed because there could be more query params that aren't required by embed and are used for things like prefilling booking form, configuring dry run, and some other params simply to be forwarded to the booking success redirect URL. // There are some cal. prefixed query params as well, not meant for embed specifically, but in general for cal.com "cal.embed.pageType"?: EmbedPageType; + // If true, then when doing a "connect" with pre-rendered iframe, we won't fetch slots. This is useful when we are reusing the iframe fully as is. + "cal.embed.noSlotsFetchOnConnect"?: "true" | "false"; }; export type EmbedBookerState = @@ -97,3 +99,6 @@ export type PrefillAndIframeAttrsConfig = Record>; +export type setNonStylesConfig = React.Dispatch>; diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index ea6446e8b0..6fe8162286 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -66,6 +66,7 @@ export const useSchedule = ({ const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined; const utils = trpc.useUtils(); const routingFormResponseIdParam = searchParams?.get("cal.routingFormResponseId"); + const queuedFormResponseId = searchParams?.get("cal.queuedFormResponseId"); 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 @@ -73,7 +74,7 @@ export const useSchedule = ({ const routingFormResponseId = routingFormResponseIdParam ? parseInt(routingFormResponseIdParam, 10) : undefined; - const embedConnectVersion = searchParams?.get("cal.embed.connectVersion") || ""; + const embedConnectVersion = searchParams?.get("cal.embed.connectVersion") || "0"; const input = { isTeamEvent, usernameList: getUsernameList(username ?? ""), @@ -94,7 +95,7 @@ export const useSchedule = ({ routedTeamMemberIds, skipContactOwner, _shouldServeCache, - routingFormResponseId, + ...(queuedFormResponseId ? { queuedFormResponseId } : { routingFormResponseId }), email, // Ensures that connectVersion causes a refresh of the data ...(embedConnectVersion ? { embedConnectVersion } : {}), diff --git a/packages/lib/server/getRoutedUrl.ts b/packages/lib/server/getRoutedUrl.ts index 21ab6d92ba..23e68a9509 100644 --- a/packages/lib/server/getRoutedUrl.ts +++ b/packages/lib/server/getRoutedUrl.ts @@ -6,13 +6,12 @@ import z from "zod"; import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData"; import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl"; -import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier"; +import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore"; import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader"; import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse"; import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute"; import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables"; -import { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse"; import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; import type { FormResponse } from "@calcom/app-store/routing-forms/types/types"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; @@ -52,8 +51,14 @@ const _getRoutedUrl = async (context: Pick { - const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || ""; - - response[field.id] = { - label: field.label, - value: getFieldResponseForJsonLogic({ field, value: fieldResponse }), - }; + const response: FormResponse = getResponseToStore({ + formFields: serializableForm.fields, + fieldsResponses, }); const matchingRoute = findMatchingRoute({ form: serializableForm, response }); - if (!matchingRoute) { throw new Error("No matching route could be found"); } @@ -122,6 +121,7 @@ const _getRoutedUrl = async (context: Pick | Prisma.JsonValue; + chosenRouteId: string | null; +} + +export class RoutingFormResponseRepository { + private static generateCreateFormResponseData( + input: RecordFormResponseInput & { queuedFormResponseId?: string | null } + ) { + return { + formId: input.formId, + response: input.response as Prisma.InputJsonValue, + chosenRouteId: input.chosenRouteId, + ...(input.queuedFormResponseId + ? { + queuedFormResponse: { + connect: { + id: input.queuedFormResponseId, + }, + }, + } + : {}), + }; + } + + static async recordFormResponse( + input: RecordFormResponseInput & { + queuedFormResponseId?: string | null; + } + ) { + return await prisma.app_RoutingForms_FormResponse.create({ + data: this.generateCreateFormResponseData(input), + }); + } + + static async recordQueuedFormResponse(input: RecordFormResponseInput) { + return await prisma.app_RoutingForms_QueuedFormResponse.create({ + data: this.generateCreateFormResponseData(input), + }); + } + + static async findFormResponseIncludeForm({ routingFormResponseId }: { routingFormResponseId: number }) { + return await prisma.app_RoutingForms_FormResponse.findUnique({ + where: { + id: routingFormResponseId, + }, + select: { + response: true, + form: { + select: { + routes: true, + fields: true, + }, + }, + chosenRouteId: true, + }, + }); + } + + static async findQueuedFormResponseIncludeForm({ queuedFormResponseId }: { queuedFormResponseId: string }) { + return await prisma.app_RoutingForms_QueuedFormResponse.findUnique({ + where: { + id: queuedFormResponseId, + }, + select: { + response: true, + form: { + select: { + routes: true, + fields: true, + }, + }, + chosenRouteId: true, + }, + }); + } + + static async getQueuedFormResponseFromId(id: string) { + return await prisma.app_RoutingForms_QueuedFormResponse.findUnique({ + where: { + id, + }, + select: { + id: true, + formId: true, + response: true, + chosenRouteId: true, + createdAt: true, + updatedAt: true, + actualResponseId: true, + form: { + select: { + team: { + select: { + parentId: true, + }, + }, + user: { + select: { + id: true, + email: true, + }, + }, + id: true, + description: true, + position: true, + routes: true, + createdAt: true, + updatedAt: true, + name: true, + fields: true, + updatedById: true, + userId: true, + teamId: true, + disabled: true, + settings: true, + }, + }, + }, + }); + } +} diff --git a/packages/prisma/migrations/20250617060130_add_queued_response_table/migration.sql b/packages/prisma/migrations/20250617060130_add_queued_response_table/migration.sql new file mode 100644 index 0000000000..9e22f6579e --- /dev/null +++ b/packages/prisma/migrations/20250617060130_add_queued_response_table/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "App_RoutingForms_QueuedFormResponse" ( + "id" TEXT NOT NULL, + "formId" TEXT NOT NULL, + "response" JSONB NOT NULL, + "chosenRouteId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3), + "actualResponseId" INTEGER, + + CONSTRAINT "App_RoutingForms_QueuedFormResponse_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "App_RoutingForms_QueuedFormResponse_actualResponseId_key" ON "App_RoutingForms_QueuedFormResponse"("actualResponseId"); + +-- AddForeignKey +ALTER TABLE "App_RoutingForms_QueuedFormResponse" ADD CONSTRAINT "App_RoutingForms_QueuedFormResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "App_RoutingForms_Form"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "App_RoutingForms_QueuedFormResponse" ADD CONSTRAINT "App_RoutingForms_QueuedFormResponse_actualResponseId_fkey" FOREIGN KEY ("actualResponseId") REFERENCES "App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e6e0d4d772..01f4ca7e9f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1138,6 +1138,7 @@ model App_RoutingForms_Form { team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) teamId Int? responses App_RoutingForms_FormResponse[] + queuedResponses App_RoutingForms_QueuedFormResponse[] disabled Boolean @default(false) /// @zod.custom(imports.RoutingFormSettings) settings Json? @@ -1156,12 +1157,13 @@ model App_RoutingForms_FormResponse { createdAt DateTime @default(now()) updatedAt DateTime? @updatedAt - routedToBookingUid String? @unique + routedToBookingUid String? @unique // We should not cascade delete the booking, because we want to keep the form response even if the routedToBooking is deleted - routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid]) + routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid]) chosenRouteId String? routingFormResponseFields RoutingFormResponseField[] routingFormResponses RoutingFormResponseDenormalized[] + queuedFormResponse App_RoutingForms_QueuedFormResponse? @@unique([formFillerId, formId]) @@index([formFillerId]) @@ -1169,6 +1171,18 @@ model App_RoutingForms_FormResponse { @@index([routedToBookingUid]) } +model App_RoutingForms_QueuedFormResponse { + id String @id @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + chosenRouteId String? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + actualResponseId Int? @unique + actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade) +} + model RoutingFormResponseField { id Int @id @default(autoincrement()) responseId Int diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index cc81e6525e..b6305474e5 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -34,6 +34,7 @@ export const getScheduleSchema = z _bypassCalendarBusyTimes: z.boolean().optional(), _shouldServeCache: z.boolean().optional(), routingFormResponseId: z.number().optional(), + queuedFormResponseId: z.string().nullish(), email: z.string().nullish(), }) .transform((val) => { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index f2fa6a1d7c..235a54e23d 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -41,6 +41,7 @@ import { withReporting } from "@calcom/lib/sentryWrapper"; import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking"; import { BookingRepository as BookingRepo } from "@calcom/lib/server/repository/booking"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; import { UserRepository, withSelectedCalendars } from "@calcom/lib/server/repository/user"; import getSlots from "@calcom/lib/slots"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; @@ -309,6 +310,7 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise