perf: Lazy load libphonenumber-js only when validation is needed. (#10812)

This commit is contained in:
Hariom Balhara
2023-08-17 10:57:30 -07:00
committed by GitHub
parent 9fb736f67a
commit 3e4139ac23
5 changed files with 203 additions and 133 deletions
@@ -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<string, unknown>;
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<HTMLDivElement>(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<z.infer<typeof bookingFormSchema>["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<z.infer<typeof bookingFormSchema>["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 (
<BookEventFormChild
// initialValues would be null initially as the async schema parsing is happening. Let's show the form in first render without any prefill values
// But ensure that when initialValues is available, the form is reset and rerendered with the prefill values
key={key}
onCancel={onCancel}
initialValues={initialValues}
isRescheduling={isRescheduling}
eventQuery={eventQuery}
rescheduleUid={rescheduleUid}
/>
);
};
export const BookEventFormChild = ({
onCancel,
initialValues,
isRescheduling,
eventQuery,
rescheduleUid,
}: BookEventFormProps & {
initialValues: DefaultValues;
isRescheduling: boolean;
eventQuery: ReturnType<typeof useEvent>;
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<HTMLDivElement>(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<typeof bookingFormSchema>["responses"];
responses: z.infer<typeof bookingFormSchema>["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<BookingFormValues>({
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 <Alert severity="warning" message={t("error_booking_event")} />;
if (event.isLoading || !event.data) return <FormSkeleton />;
if (eventQuery.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
if (eventQuery.isLoading || !eventQuery.data) return <FormSkeleton />;
if (!timeslot)
return (
<EmptyScreen
@@ -319,26 +287,26 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
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<typeof useEvent>["data"];
rescheduleUid: string | null;
isRescheduling: boolean;
}) {
const [initialValues, setDefaultValues] = useState<DefaultValues>({});
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<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
};
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<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
};
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 };
}
@@ -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<T extends z.ZodType>({
});
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<T extends z.ZodType>({
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<T extends z.ZodType>({
message: m("email_validation_error"),
});
}
return;
continue;
}
const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap];
@@ -159,7 +161,7 @@ function preprocess<T extends z.ZodType>({
field: bookingField,
isPartialSchema,
});
return;
continue;
}
if (bookingField.type === "multiemail") {
@@ -169,7 +171,7 @@ function preprocess<T extends z.ZodType>({
code: z.ZodIssueCode.custom,
message: m("email_validation_error"),
});
return;
continue;
}
const emails = emailsParsed.data;
@@ -179,21 +181,21 @@ function preprocess<T extends z.ZodType>({
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<T extends z.ZodType>({
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<T extends z.ZodType>({
}
}
}
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<T extends z.ZodType>({
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) {
@@ -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<typeof bookingDataSchema>;
@@ -671,7 +671,7 @@ async function handler(
smsReminderNumber,
rescheduleReason,
...reqBody
} = getBookingData({
} = await getBookingData({
req,
isNotAnApiCall,
eventType,
@@ -81,7 +81,7 @@ type Component =
TProps extends Omit<TextLikeComponentProps, "value" | "setValue"> & {
variant: string | undefined;
variants: z.infer<typeof variantsConfigSchema>["variants"];
value: Record<string, string> | string;
value: Record<string, string> | string | undefined;
setValue: (value: string | Record<string, string>) => void;
}
>(
+4 -3
View File
@@ -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