diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 36eeda9958..a162d31b0f 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; import type { TFunction } from "next-i18next"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { FieldError } from "react-hook-form"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -42,36 +42,23 @@ type BookEventFormProps = { onCancel?: () => void; }; +type DefaultValues = Record; + export const BookEventForm = ({ onCancel }: BookEventFormProps) => { - const searchParams = useSearchParams(); - const routerQuery = useRouterQuery(); - const session = useSession(); - const bookingSuccessRedirect = useBookingSuccessRedirect(); const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({ trpc: { context: { skipBatch: true } }, }); const removeSelectedSlot = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({ trpc: { context: { skipBatch: true } }, }); - const router = useRouter(); - const { t, i18n } = useLocale(); - const { timezone } = useTimePreferences(); - const errorRef = useRef(null); + const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const bookingData = useBookerStore((state) => state.bookingData); - const eventSlug = useBookerStore((state) => state.eventSlug); const duration = useBookerStore((state) => state.selectedDuration); const timeslot = useBookerStore((state) => state.selectedTimeslot); - const recurringEventCount = useBookerStore((state) => state.recurringEventCount); - const username = useBookerStore((state) => state.username); - const formValues = useBookerStore((state) => state.formValues); - const setFormValues = useBookerStore((state) => state.setFormValues); - const seatedEventData = useBookerStore((state) => state.seatedEventData); - const verifiedEmail = useBookerStore((state) => state.verifiedEmail); - const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail); const isRescheduling = !!rescheduleUid && !!bookingData; - const event = useEvent(); - const eventType = event.data; + const eventQuery = useEvent(); + const eventType = eventQuery.data; const reserveSlot = () => { if (eventType?.id && timeslot && (duration || eventType?.length)) { @@ -102,107 +89,88 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventType?.id, timeslot]); - const defaultValues = useMemo(() => { - if (Object.keys(formValues).length) return formValues; + const { initialValues, key } = useInitialFormValues({ + eventType, + rescheduleUid, + isRescheduling, + }); - if (!eventType?.bookingFields) { - return {}; - } - const querySchema = getBookingResponsesPartialSchema({ - eventType: { - bookingFields: eventType.bookingFields, - }, - view: rescheduleUid ? "reschedule" : "booking", - }); - - const parsedQuery = querySchema.parse({ - ...routerQuery, - // `guest` because we need to support legacy URL with `guest` query param support - // `guests` because the `name` of the corresponding bookingField is `guests` - guests: searchParams?.getAll("guests") || searchParams?.getAll("guest"), - }); - - const defaultUserValues = { - email: rescheduleUid - ? bookingData?.attendees[0].email - : parsedQuery["email"] || session.data?.user?.email || "", - name: rescheduleUid - ? bookingData?.attendees[0].name - : parsedQuery["name"] || session.data?.user?.name || "", - }; - - if (!isRescheduling) { - const defaults = { - responses: {} as Partial["responses"]>, - }; - - const responses = eventType.bookingFields.reduce((responses, field) => { - return { - ...responses, - [field.name]: parsedQuery[field.name] || undefined, - }; - }, {}); - - defaults.responses = { - ...responses, - name: defaultUserValues.name, - email: defaultUserValues.email, - }; - - return defaults; - } - - if ((!rescheduleUid && !bookingData) || !bookingData.attendees.length) { - return {}; - } - const primaryAttendee = bookingData.attendees[0]; - if (!primaryAttendee) { - return {}; - } - - const defaults = { - responses: {} as Partial["responses"]>, - }; - - const responses = eventType.bookingFields.reduce((responses, field) => { - return { - ...responses, - [field.name]: bookingData.responses[field.name], - }; - }, {}); - defaults.responses = { - ...responses, - name: defaultUserValues.name, - email: defaultUserValues.email, - }; - return defaults; - }, [eventType?.bookingFields, formValues, isRescheduling, bookingData, rescheduleUid]); + return ( + + ); +}; +export const BookEventFormChild = ({ + onCancel, + initialValues, + isRescheduling, + eventQuery, + rescheduleUid, +}: BookEventFormProps & { + initialValues: DefaultValues; + isRescheduling: boolean; + eventQuery: ReturnType; + rescheduleUid: string | null; +}) => { + const eventType = eventQuery.data; const bookingFormSchema = z .object({ - responses: event?.data + responses: eventQuery?.data ? getBookingResponsesSchema({ - eventType: event?.data, + eventType: eventQuery?.data, view: rescheduleUid ? "reschedule" : "booking", }) : // Fallback until event is loaded. z.object({}), }) .passthrough(); + const searchParams = useSearchParams(); + const routerQuery = useRouterQuery(); + const setFormValues = useBookerStore((state) => state.setFormValues); + const seatedEventData = useBookerStore((state) => state.seatedEventData); + const verifiedEmail = useBookerStore((state) => state.verifiedEmail); + const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail); + const bookingSuccessRedirect = useBookingSuccessRedirect(); + const router = useRouter(); + const { t, i18n } = useLocale(); + const { timezone } = useTimePreferences(); + const errorRef = useRef(null); + const bookingData = useBookerStore((state) => state.bookingData); + const eventSlug = useBookerStore((state) => state.eventSlug); + const duration = useBookerStore((state) => state.selectedDuration); + const timeslot = useBookerStore((state) => state.selectedTimeslot); + const recurringEventCount = useBookerStore((state) => state.recurringEventCount); + const username = useBookerStore((state) => state.username); type BookingFormValues = { locationType?: EventLocationType["type"]; - responses: z.infer["responses"]; + responses: z.infer["responses"] | null; // Key is not really part of form values, but only used to have a key // to set generic error messages on. Needed until RHF has implemented root error keys. globalError: undefined; }; const bookingForm = useForm({ - defaultValues, - resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema + defaultValues: initialValues, + resolver: zodResolver( + // Since this isn't set to strict we only validate the fields in the schema + bookingFormSchema, + {}, + { + // bookingFormSchema is an async schema, so inform RHF to do async validation. + mode: "async", + } + ), }); - const createBookingMutation = useMutation(createBooking, { onSuccess: (responseData) => { const { uid, paymentUid } = responseData; @@ -287,7 +255,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { // It shouldn't be possible that this method is fired without having event data, // but since in theory (looking at the types) it is possible, we still handle that case. - if (!event?.data) { + if (!eventQuery?.data) { bookingForm.setError("globalError", { message: t("error_booking_event") }); return; } @@ -301,8 +269,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { setEmailVerificationModalVisible(true); }; - if (event.isError) return ; - if (event.isLoading || !event.data) return ; + if (eventQuery.isError) return ; + if (eventQuery.isLoading || !eventQuery.data) return ; if (!timeslot) return ( { setFormValues({}); bookingForm.clearErrors(); - // It shouldn't be possible that this method is fired without having event data, + // It shouldn't be possible that this method is fired without having eventQuery data, // but since in theory (looking at the types) it is possible, we still handle that case. - if (!event?.data) { + if (!eventQuery?.data) { bookingForm.setError("globalError", { message: t("error_booking_event") }); return; } // Ensures that duration is an allowed value, if not it defaults to the - // default event duration. + // default eventQuery duration. const validDuration = duration && - event.data.metadata?.multipleDuration && - event.data.metadata?.multipleDuration.includes(duration) + eventQuery.data.metadata?.multipleDuration && + eventQuery.data.metadata?.multipleDuration.includes(duration) ? duration - : event.data.length; + : eventQuery.data.length; const bookingInput = { values, duration: validDuration, - event: event.data, + event: eventQuery.data, date: timeslot, timeZone: timezone, language: i18n.language, @@ -356,7 +324,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { ), }; - if (event.data?.recurringEvent?.freq && recurringEventCount) { + if (eventQuery.data?.recurringEvent?.freq && recurringEventCount) { createRecurringBookingMutation.mutate( mapRecurringBookingToMutationInput(bookingInput, recurringEventCount) ); @@ -465,3 +433,102 @@ const getError = ( "Unknown error" ); }; + +function useInitialFormValues({ + eventType, + rescheduleUid, + isRescheduling, +}: { + eventType: ReturnType["data"]; + rescheduleUid: string | null; + isRescheduling: boolean; +}) { + const [initialValues, setDefaultValues] = useState({}); + const bookingData = useBookerStore((state) => state.bookingData); + const formValues = useBookerStore((state) => state.formValues); + const searchParams = useSearchParams(); + const routerQuery = useRouterQuery(); + const session = useSession(); + useEffect(() => { + (async function () { + if (Object.keys(formValues).length) return formValues; + + if (!eventType?.bookingFields) { + return {}; + } + const querySchema = getBookingResponsesPartialSchema({ + eventType: { + bookingFields: eventType.bookingFields, + }, + view: rescheduleUid ? "reschedule" : "booking", + }); + + const parsedQuery = await querySchema.parseAsync({ + ...routerQuery, + // `guest` because we need to support legacy URL with `guest` query param support + // `guests` because the `name` of the corresponding bookingField is `guests` + guests: searchParams?.getAll("guests") || searchParams?.getAll("guest"), + }); + + const defaultUserValues = { + email: rescheduleUid + ? bookingData?.attendees[0].email + : parsedQuery["email"] || session.data?.user?.email || "", + name: rescheduleUid + ? bookingData?.attendees[0].name + : parsedQuery["name"] || session.data?.user?.name || "", + }; + + if (!isRescheduling) { + const defaults = { + responses: {} as Partial>>, + }; + + const responses = eventType.bookingFields.reduce((responses, field) => { + return { + ...responses, + [field.name]: parsedQuery[field.name] || undefined, + }; + }, {}); + + defaults.responses = { + ...responses, + name: defaultUserValues.name, + email: defaultUserValues.email, + }; + + setDefaultValues(defaults); + } + + if ((!rescheduleUid && !bookingData) || !bookingData?.attendees.length) { + return {}; + } + const primaryAttendee = bookingData.attendees[0]; + if (!primaryAttendee) { + return {}; + } + + const defaults = { + responses: {} as Partial>>, + }; + + const responses = eventType.bookingFields.reduce((responses, field) => { + return { + ...responses, + [field.name]: bookingData.responses[field.name], + }; + }, {}); + defaults.responses = { + ...responses, + name: defaultUserValues.name, + email: defaultUserValues.email, + }; + setDefaultValues(defaults); + })(); + }, [eventType?.bookingFields, formValues, isRescheduling, bookingData, rescheduleUid]); + + // When initialValues is available(after doing async schema parsing) or session is available(so that we can prefill logged-in user email and name), we need to reset the form with the initialValues + const key = `${Object.keys(initialValues).length}_${session ? 1 : 0}`; + + return { initialValues, key }; +} diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 75231f6095..141362a841 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -1,4 +1,3 @@ -import { isValidPhoneNumber } from "libphonenumber-js"; import z from "zod"; import type { ALL_VIEWS } from "@calcom/features/form-builder/schema"; @@ -106,18 +105,21 @@ function preprocess({ }); return newResponses; }, - schema.superRefine((responses, ctx) => { + schema.superRefine(async (responses, ctx) => { if (!eventType.bookingFields) { // if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses. return; } - eventType.bookingFields.forEach((bookingField) => { + for (const bookingField of eventType.bookingFields) { const value = responses[bookingField.name]; const stringSchema = z.string(); const emailSchema = isPartialSchema ? z.string() : z.string().refine(emailSchemaRefinement); const phoneSchema = isPartialSchema ? z.string() - : z.string().refine((val) => isValidPhoneNumber(val)); + : z.string().refine(async (val) => { + const { isValidPhoneNumber } = await import("libphonenumber-js"); + return isValidPhoneNumber(val); + }); // Tag the message with the input name so that the message can be shown at appropriate place const m = (message: string) => `{${bookingField.name}}${message}`; const views = bookingField.views; @@ -132,7 +134,7 @@ function preprocess({ const isRequired = hidden ? false : isFieldApplicableToCurrentView ? bookingField.required : false; if ((isPartialSchema || !isRequired) && value === undefined) { - return; + continue; } if (isRequired && !isPartialSchema && !value) @@ -146,7 +148,7 @@ function preprocess({ message: m("email_validation_error"), }); } - return; + continue; } const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap]; @@ -159,7 +161,7 @@ function preprocess({ field: bookingField, isPartialSchema, }); - return; + continue; } if (bookingField.type === "multiemail") { @@ -169,7 +171,7 @@ function preprocess({ code: z.ZodIssueCode.custom, message: m("email_validation_error"), }); - return; + continue; } const emails = emailsParsed.data; @@ -179,21 +181,21 @@ function preprocess({ return true; } }); - return; + continue; } if (bookingField.type === "checkbox" || bookingField.type === "multiselect") { if (!stringSchema.array().safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") }); } - return; + continue; } if (bookingField.type === "phone") { - if (!phoneSchema.safeParse(value).success) { + if (!(await phoneSchema.safeParseAsync(value)).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") }); } - return; + continue; } if (bookingField.type === "boolean") { @@ -201,7 +203,7 @@ function preprocess({ if (!schema.safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") }); } - return; + continue; } if (bookingField.type === "radioInput") { @@ -227,7 +229,7 @@ function preprocess({ } } } - return; + continue; } // Use fieldTypeConfig.propsType to validate for propsType=="text" or propsType=="select" as in those cases, the response would be a string. @@ -237,11 +239,11 @@ function preprocess({ if (!schema.safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") }); } - return; + continue; } throw new Error(`Can't parse unknown booking field type: ${bookingField.type}`); - }); + } }) ); if (isPartialSchema) { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2698feda2f..31668800f4 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -464,7 +464,7 @@ async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boole }); } -function getBookingData({ +async function getBookingData({ req, isNotAnApiCall, eventType, @@ -534,7 +534,7 @@ function getBookingData({ } }); - const reqBody = bookingDataSchema.parse(req.body); + const reqBody = await bookingDataSchema.parseAsync(req.body); // Work with Typescript to require reqBody.end type ReqBodyWithoutEnd = z.infer; @@ -671,7 +671,7 @@ async function handler( smsReminderNumber, rescheduleReason, ...reqBody - } = getBookingData({ + } = await getBookingData({ req, isNotAnApiCall, eventType, diff --git a/packages/features/form-builder/Components.tsx b/packages/features/form-builder/Components.tsx index 2b3a0434f8..6fde7cc172 100644 --- a/packages/features/form-builder/Components.tsx +++ b/packages/features/form-builder/Components.tsx @@ -81,7 +81,7 @@ type Component = TProps extends Omit & { variant: string | undefined; variants: z.infer["variants"]; - value: Record | string; + value: Record | string | undefined; setValue: (value: string | Record) => void; } >( diff --git a/packages/features/form-builder/utils.ts b/packages/features/form-builder/utils.ts index db90fcd2ea..fdd7dea0ed 100644 --- a/packages/features/form-builder/utils.ts +++ b/packages/features/form-builder/utils.ts @@ -5,7 +5,7 @@ import type { fieldSchema } from "./schema"; export const preprocessNameFieldDataWithVariant = ( variantName: "fullName" | "firstAndLastName", - value: string | Record<"firstName" | "lastName", string> + value: string | Record<"firstName" | "lastName", string> | undefined ) => { // We expect an object here, but if we get a string, then we will try to transform it into the appropriate object if (variantName === "firstAndLastName") { @@ -16,7 +16,7 @@ export const preprocessNameFieldDataWithVariant = ( } }; -export const getFullName = (name: string | { firstName: string; lastName?: string }) => { +export const getFullName = (name: string | { firstName: string; lastName?: string } | undefined) => { if (!name) { return ""; } @@ -29,8 +29,9 @@ export const getFullName = (name: string | { firstName: string; lastName?: strin return nameString; }; -function getFirstAndLastName(value: string | Record<"firstName" | "lastName", string>) { +function getFirstAndLastName(value: string | Record<"firstName" | "lastName", string> | undefined) { let newValue: Record<"firstName" | "lastName", string>; + value = value || ""; if (typeof value === "string") { try { // Support name={"firstName": "John", "lastName": "Johny Janardan"} for prefilling