Implement Add Guests and other fixes
parent
70f19289dd
commit
e83c83d951
|
@ -33,25 +33,8 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import {
|
import { Button, Form, Tooltip } from "@calcom/ui";
|
||||||
AddressInput,
|
import { FiCalendar, FiCreditCard, FiRefreshCw, FiUser, FiAlertTriangle } from "@calcom/ui/components/icon";
|
||||||
Button,
|
|
||||||
EmailField,
|
|
||||||
EmailInput,
|
|
||||||
Form,
|
|
||||||
Group,
|
|
||||||
PhoneInput,
|
|
||||||
RadioField,
|
|
||||||
Tooltip,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
import {
|
|
||||||
FiUserPlus,
|
|
||||||
FiCalendar,
|
|
||||||
FiCreditCard,
|
|
||||||
FiRefreshCw,
|
|
||||||
FiUser,
|
|
||||||
FiAlertTriangle,
|
|
||||||
} from "@calcom/ui/components/icon";
|
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
|
@ -87,6 +70,7 @@ const BookingFields = ({
|
||||||
if (field.hidden) return null;
|
if (field.hidden) return null;
|
||||||
let readOnly =
|
let readOnly =
|
||||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
||||||
|
//TODO: `rescheduleReason` should be an enum or similar to avoid typos
|
||||||
if (field.name === "rescheduleReason") {
|
if (field.name === "rescheduleReason") {
|
||||||
if (!rescheduleUid) {
|
if (!rescheduleUid) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -120,6 +104,8 @@ const BookingFields = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
field.label = t(field.label);
|
||||||
|
|
||||||
return <FormBuilderField field={field} readOnly={readOnly} key={index} />;
|
return <FormBuilderField field={field} readOnly={readOnly} key={index} />;
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
@ -222,19 +208,26 @@ const BookingPage = ({
|
||||||
const rescheduleUid = router.query.rescheduleUid as string;
|
const rescheduleUid = router.query.rescheduleUid as string;
|
||||||
useTheme(profile.theme);
|
useTheme(profile.theme);
|
||||||
const date = asStringOrNull(router.query.date);
|
const date = asStringOrNull(router.query.date);
|
||||||
|
const querySchema = getBookingResponsesSchema(
|
||||||
|
{
|
||||||
|
bookingFields: eventType.bookingFields,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
// string value for - text, textarea, select, radio,
|
||||||
|
// string value with , for checkbox and multiselect
|
||||||
|
// Object {value:"", optionValue:""} for radioInput
|
||||||
|
const parsedQuery = querySchema.parse({
|
||||||
|
...router.query,
|
||||||
|
guests: router.query.guest,
|
||||||
|
});
|
||||||
|
|
||||||
const [guestToggle, setGuestToggle] = useState(booking && booking.attendees.length > 1);
|
|
||||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||||
const locations: LocationObject[] = useMemo(
|
const locations: LocationObject[] = useMemo(
|
||||||
() => (eventType.locations as LocationObject[]) || [],
|
() => (eventType.locations as LocationObject[]) || [],
|
||||||
[eventType.locations]
|
[eventType.locations]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (router.query.guest) {
|
|
||||||
setGuestToggle(true);
|
|
||||||
}
|
|
||||||
}, [router.query.guest]);
|
|
||||||
const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false);
|
const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// THis is to fix hydration error that comes because of different timezone on server and client
|
// THis is to fix hydration error that comes because of different timezone on server and client
|
||||||
|
@ -242,24 +235,9 @@ const BookingPage = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||||
const guestListEmails = !isDynamicGroupBooking
|
|
||||||
? booking?.attendees.slice(1).map((attendee) => {
|
|
||||||
return { email: attendee.email };
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
//FIXME: We need to be backward compatible in terms of pre-filling the form
|
|
||||||
const getFormBuilderFieldValueFromQuery = (paramName: string) => {
|
const getFormBuilderFieldValueFromQuery = (paramName: string) => {
|
||||||
const schema = getBookingResponsesSchema(
|
return parsedQuery[paramName];
|
||||||
{
|
|
||||||
bookingFields: eventType.bookingFields,
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
// string value for - text, textarea, select, radio,
|
|
||||||
// string value with , for checkbox and multiselect
|
|
||||||
// Object {value:"", optionValue:""} for radioInput
|
|
||||||
return schema.parse(router.query)[paramName];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// There should only exists one default userData variable for primaryAttendee.
|
// There should only exists one default userData variable for primaryAttendee.
|
||||||
|
@ -323,17 +301,7 @@ const BookingPage = ({
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
type BookingFormValues = {
|
type BookingFormValues = {
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
notes?: string;
|
|
||||||
locationType?: EventLocationType["type"];
|
locationType?: EventLocationType["type"];
|
||||||
guests?: string[];
|
|
||||||
address?: string;
|
|
||||||
attendeeAddress?: string;
|
|
||||||
phone?: string;
|
|
||||||
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
|
|
||||||
rescheduleReason?: string;
|
|
||||||
smsReminderNumber?: string;
|
|
||||||
responses: z.infer<typeof bookingFormSchema>["responses"];
|
responses: z.infer<typeof bookingFormSchema>["responses"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||||
|
|
|
@ -1050,6 +1050,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookingInfo = {
|
const bookingInfo = {
|
||||||
...bookingInfoRaw,
|
...bookingInfoRaw,
|
||||||
responses: getBookingResponsesSchema(eventTypeRaw).parse(bookingInfoRaw.responses),
|
responses: getBookingResponsesSchema(eventTypeRaw).parse(bookingInfoRaw.responses),
|
||||||
|
|
|
@ -190,7 +190,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
startDate: periodDates.startDate,
|
startDate: periodDates.startDate,
|
||||||
endDate: periodDates.endDate,
|
endDate: periodDates.endDate,
|
||||||
},
|
},
|
||||||
bookingFields: eventType.bookingFields,
|
bookingFields: eventType.bookingFields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
label: t(field.label),
|
||||||
|
})),
|
||||||
periodType: eventType.periodType,
|
periodType: eventType.periodType,
|
||||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||||
schedulingType: eventType.schedulingType,
|
schedulingType: eventType.schedulingType,
|
||||||
|
|
|
@ -231,7 +231,8 @@ export const useIsEmbed = (embedSsr?: boolean) => {
|
||||||
const _isValidNamespace = isValidNamespace(namespace);
|
const _isValidNamespace = isValidNamespace(namespace);
|
||||||
if (parent !== window && !_isValidNamespace) {
|
if (parent !== window && !_isValidNamespace) {
|
||||||
log(
|
log(
|
||||||
"Looks like you have iframed cal.com but not using Embed Snippet. Directly using an iframe isn't recommended."
|
`Looks like you have iframed cal.com but not using Embed Snippet.
|
||||||
|
Directly using an iframe isn't recommended.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setIsEmbed(window?.isEmbed?.() || false);
|
setIsEmbed(window?.isEmbed?.() || false);
|
||||||
|
|
|
@ -5,11 +5,13 @@ import { bookingResponses, eventTypeBookingFields } from "@calcom/prisma/zod-uti
|
||||||
|
|
||||||
export default function getBookingResponsesSchema(
|
export default function getBookingResponsesSchema(
|
||||||
eventType: {
|
eventType: {
|
||||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
bookingFields: z.infer<typeof eventTypeBookingFields> &
|
||||||
|
z.infer<typeof eventTypeBookingFields> &
|
||||||
|
z.BRAND<"HAS_SYSTEM_FIELDS">;
|
||||||
},
|
},
|
||||||
partial = false
|
forgiving = false
|
||||||
) {
|
) {
|
||||||
const schema = partial
|
const schema = forgiving
|
||||||
? bookingResponses.partial().and(z.record(z.any()))
|
? bookingResponses.partial().and(z.record(z.any()))
|
||||||
: bookingResponses.and(z.record(z.any()));
|
: bookingResponses.and(z.record(z.any()));
|
||||||
|
|
||||||
|
@ -32,53 +34,64 @@ export default function getBookingResponsesSchema(
|
||||||
});
|
});
|
||||||
return newResponses;
|
return newResponses;
|
||||||
},
|
},
|
||||||
schema.superRefine((response, ctx) => {
|
schema.superRefine((responses, ctx) => {
|
||||||
eventType.bookingFields.forEach((input) => {
|
eventType.bookingFields.forEach((bookingField) => {
|
||||||
const value = response[input.name];
|
const value = responses[bookingField.name];
|
||||||
|
const emailSchema = forgiving ? z.string() : z.string().email();
|
||||||
|
const phoneSchema = forgiving ? z.string() : z.string().refine((val) => isValidPhoneNumber(val));
|
||||||
// Tag the message with the input name so that the message can be shown at appropriate plae
|
// Tag the message with the input name so that the message can be shown at appropriate plae
|
||||||
const m = (message: string) => `{${input.name}}${message}`;
|
const m = (message: string) => `{${bookingField.name}}${message}`;
|
||||||
if ((partial || !input.required) && value === undefined) {
|
if ((forgiving || !bookingField.required) && value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (input.required && !partial && !value)
|
if (bookingField.required && !forgiving && !value)
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`Required`) });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`Required`) });
|
||||||
|
|
||||||
if (input.type === "email") {
|
if (bookingField.type === "email") {
|
||||||
// Email RegExp to validate if the input is a valid email
|
// Email RegExp to validate if the input is a valid email
|
||||||
if (!z.string().email().safeParse(value).success)
|
if (!emailSchema.safeParse(value).success) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
//TODO: How to do translation in booker language here?
|
//TODO: How to do translation in booker language here?
|
||||||
message: m("That doesn't look like an email address"),
|
message: m("That doesn't look like an email address"),
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bookingField.type === "multiemail") {
|
||||||
|
if (!emailSchema.array().safeParse(value).success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
//TODO: How to do translation in booker language here?
|
||||||
|
message: m("That doesn't look like an email address"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.type === "phone") {
|
if (bookingField.type === "phone") {
|
||||||
if (
|
if (!phoneSchema.safeParse(value).success) {
|
||||||
!z
|
|
||||||
.string()
|
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
|
||||||
.optional()
|
|
||||||
.nullable()
|
|
||||||
.safeParse(value).success
|
|
||||||
) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Phone") });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Phone") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.type === "boolean") {
|
if (bookingField.type === "boolean") {
|
||||||
const schema = z.preprocess((val) => {
|
const schema = z.boolean();
|
||||||
return val === "true";
|
|
||||||
}, z.boolean());
|
|
||||||
if (!schema.safeParse(value).success) {
|
if (!schema.safeParse(value).success) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.type === "radioInput" && input.optionsInputs) {
|
|
||||||
|
if (bookingField.type === "radioInput" && bookingField.optionsInputs) {
|
||||||
//FIXME: ManageBookings: If there is just one option then it is not required to show the radio options
|
//FIXME: ManageBookings: If there is just one option then it is not required to show the radio options
|
||||||
// Also, if the option is there with one input, we need to show just the input and not radio
|
// Also, if the option is there with one input, we need to show just the input and not radio
|
||||||
if (input.required && input.optionsInputs[value?.value]?.required && !value?.optionValue) {
|
if (
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Required") });
|
bookingField.required &&
|
||||||
|
bookingField.optionsInputs[value?.value]?.required &&
|
||||||
|
!value?.optionValue
|
||||||
|
) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Required Option Value") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -328,7 +328,7 @@ function getBookingData({
|
||||||
...reqBody,
|
...reqBody,
|
||||||
name: responses.name,
|
name: responses.name,
|
||||||
email: responses.email,
|
email: responses.email,
|
||||||
guests: responses.guests ? responses.guests.split(",") : [],
|
guests: responses.guests ? responses.guests : [],
|
||||||
location: responses.location?.optionValue || responses.location?.value || "",
|
location: responses.location?.optionValue || responses.location?.value || "",
|
||||||
smsReminderNumber: responses.smsReminderNumber,
|
smsReminderNumber: responses.smsReminderNumber,
|
||||||
notes: responses.notes,
|
notes: responses.notes,
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import Widgets, {
|
import Widgets, {
|
||||||
TextLikeComponentProps,
|
TextLikeComponentProps,
|
||||||
SelectLikeComponentProps,
|
SelectLikeComponentProps,
|
||||||
} from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets";
|
} from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets";
|
||||||
import { PhoneInput, AddressInput, Label, Group, RadioField } from "@calcom/ui";
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { PhoneInput, AddressInput, Button, Label, Group, RadioField, EmailField, Tooltip } from "@calcom/ui";
|
||||||
|
import { FiInfo, FiUserPlus, FiX } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { ComponentForField } from "./FormBuilder";
|
import { ComponentForField } from "./FormBuilder";
|
||||||
import { fieldsSchema } from "./FormBuilderFieldsSchema";
|
import { fieldsSchema } from "./FormBuilderFieldsSchema";
|
||||||
|
@ -71,12 +74,110 @@ export const Components = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
multiemail: {
|
multiemail: {
|
||||||
propsType: "text",
|
propsType: "textList",
|
||||||
factory: <TProps extends TextLikeComponentProps>(props: TProps) => {
|
factory: <TProps extends SelectLikeComponentProps<string[]>>({
|
||||||
//TODO: ManageBookings: Make it use multiemail
|
value,
|
||||||
return <Widgets.TextWidget type="email" {...props} />;
|
label,
|
||||||
|
setValue,
|
||||||
|
...props
|
||||||
|
}: TProps) => {
|
||||||
|
const placeholder = props.placeholder;
|
||||||
|
const { t } = useLocale();
|
||||||
|
value = value || [];
|
||||||
|
const inputClassName =
|
||||||
|
"dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{value.length ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="guests"
|
||||||
|
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<ul>
|
||||||
|
{value.map((field, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<EmailField
|
||||||
|
value={value[index]}
|
||||||
|
onChange={(e) => {
|
||||||
|
value[index] = e.target.value;
|
||||||
|
setValue(value);
|
||||||
|
}}
|
||||||
|
className={classNames(
|
||||||
|
inputClassName,
|
||||||
|
// bookingForm.formState.errors.guests?.[index] &&
|
||||||
|
// "!focus:ring-red-700 !border-red-700",
|
||||||
|
"border-r-0"
|
||||||
|
)}
|
||||||
|
addOnClassname={classNames(
|
||||||
|
"border-gray-300 border block border-l-0 disabled:bg-gray-200 disabled:hover:cursor-not-allowed bg-transparent disabled:text-gray-500 dark:border-darkgray-300 "
|
||||||
|
// bookingForm.formState.errors.guests?.[index] &&
|
||||||
|
// "!focus:ring-red-700 !border-red-700"
|
||||||
|
)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={<></>}
|
||||||
|
required
|
||||||
|
addOnSuffix={
|
||||||
|
<Tooltip content="Remove email">
|
||||||
|
<button
|
||||||
|
className="m-1 disabled:hover:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
value.splice(index, 1);
|
||||||
|
setValue(value);
|
||||||
|
}}>
|
||||||
|
<FiX className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* {bookingForm.formState.errors.guests?.[index] && (
|
||||||
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
|
<p className="text-red-700">
|
||||||
|
{bookingForm.formState.errors.guests?.[index]?.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={FiUserPlus}
|
||||||
|
className="my-2.5"
|
||||||
|
// className="mb-1 block text-sm font-medium text-gray-700 dark:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
value.push("");
|
||||||
|
setValue(value);
|
||||||
|
}}>
|
||||||
|
{t("add_another")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!value.length && (
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
variant="button"
|
||||||
|
StartIcon={FiUserPlus}
|
||||||
|
onClick={() => {
|
||||||
|
value.push("");
|
||||||
|
setValue(value);
|
||||||
|
}}
|
||||||
|
className="mr-auto">
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
valuePlaceholder: "Enter Email Addresses",
|
|
||||||
},
|
},
|
||||||
multiselect: {
|
multiselect: {
|
||||||
propsType: "multiselect",
|
propsType: "multiselect",
|
||||||
|
|
|
@ -33,6 +33,10 @@ type RhfForm = {
|
||||||
type RhfFormFields = RhfForm["fields"];
|
type RhfFormFields = RhfForm["fields"];
|
||||||
type RhfFormField = RhfFormFields[number];
|
type RhfFormField = RhfFormFields[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It works with a react-hook-form only.
|
||||||
|
* `formProp` specifies the name of the property in the react-hook-form that has the fields. This is where fields would be updated.
|
||||||
|
*/
|
||||||
export const FormBuilder = function FormBuilder({
|
export const FormBuilder = function FormBuilder({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
@ -49,62 +53,76 @@ export const FormBuilder = function FormBuilder({
|
||||||
label: string;
|
label: string;
|
||||||
needsOptions?: boolean;
|
needsOptions?: boolean;
|
||||||
systemOnly?: boolean;
|
systemOnly?: boolean;
|
||||||
|
isTextType?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
label: "Name",
|
label: "Name",
|
||||||
value: "name",
|
value: "name",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Email",
|
label: "Email",
|
||||||
value: "email",
|
value: "email",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Phone",
|
label: "Phone",
|
||||||
value: "phone",
|
value: "phone",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Short Text",
|
label: "Short Text",
|
||||||
value: "text",
|
value: "text",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Number",
|
label: "Number",
|
||||||
value: "number",
|
value: "number",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Long Text",
|
label: "Long Text",
|
||||||
value: "textarea",
|
value: "textarea",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Select",
|
label: "Select",
|
||||||
value: "select",
|
value: "select",
|
||||||
needsOptions: true,
|
needsOptions: true,
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "MultiSelect",
|
label: "MultiSelect",
|
||||||
value: "multiselect",
|
value: "multiselect",
|
||||||
needsOptions: true,
|
needsOptions: true,
|
||||||
|
isTextType: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Multiple Emails",
|
label: "Multiple Emails",
|
||||||
value: "multiemail",
|
value: "multiemail",
|
||||||
|
isTextType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Radio Input",
|
label: "Radio Input",
|
||||||
value: "radioInput",
|
value: "radioInput",
|
||||||
|
isTextType: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Checkbox Group",
|
label: "Checkbox Group",
|
||||||
value: "checkbox",
|
value: "checkbox",
|
||||||
needsOptions: true,
|
needsOptions: true,
|
||||||
|
isTextType: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Radio Group",
|
label: "Radio Group",
|
||||||
value: "radio",
|
value: "radio",
|
||||||
needsOptions: true,
|
needsOptions: true,
|
||||||
|
isTextType: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Checkbox",
|
label: "Checkbox",
|
||||||
value: "boolean",
|
value: "boolean",
|
||||||
|
isTextType: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
// I would have like to give Form Builder it's own Form but nested Forms aren't something that browsers support.
|
// I would have like to give Form Builder it's own Form but nested Forms aren't something that browsers support.
|
||||||
|
@ -143,7 +161,6 @@ export const FormBuilder = function FormBuilder({
|
||||||
value: "2",
|
value: "2",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
// const [optionsState, setOptionsState] = useState(options);
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
|
@ -363,7 +380,6 @@ export const FormBuilder = function FormBuilder({
|
||||||
options={FieldTypes.filter((f) => !f.systemOnly)}
|
options={FieldTypes.filter((f) => !f.systemOnly)}
|
||||||
label="Input Type"
|
label="Input Type"
|
||||||
/>
|
/>
|
||||||
<InputField {...fieldForm.register("label")} required containerClassName="mt-6" label="Label" />
|
|
||||||
<InputField
|
<InputField
|
||||||
required
|
required
|
||||||
{...fieldForm.register("name")}
|
{...fieldForm.register("name")}
|
||||||
|
@ -374,6 +390,15 @@ export const FormBuilder = function FormBuilder({
|
||||||
}
|
}
|
||||||
label="Name"
|
label="Name"
|
||||||
/>
|
/>
|
||||||
|
<InputField {...fieldForm.register("label")} required containerClassName="mt-6" label="Label" />
|
||||||
|
{fieldType?.isTextType ? (
|
||||||
|
<InputField
|
||||||
|
{...fieldForm.register("placeholder")}
|
||||||
|
containerClassName="mt-6"
|
||||||
|
label="Placeholder"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{fieldType?.needsOptions ? (
|
{fieldType?.needsOptions ? (
|
||||||
<Controller
|
<Controller
|
||||||
name="options"
|
name="options"
|
||||||
|
@ -420,17 +445,16 @@ const WithLabel = ({
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center">
|
{field.type !== "boolean" && field.type !== "multiemail" && (
|
||||||
{field.type !== "boolean" && <Label className="!mb-0">{field.label}</Label>}
|
<div className="mb-2 flex items-center">
|
||||||
{!readOnly && !field.required && (
|
<Label className="!mb-0 flex items-center">{field.label}</Label>
|
||||||
<Badge className="ml-2" variant="gray">
|
<span className="ml-1 -mb-1 text-sm font-medium leading-none">
|
||||||
{t("optional")}
|
{!readOnly && field.required ? "*" : ""}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -463,21 +487,16 @@ export const ComponentForField = ({
|
||||||
setValue,
|
setValue,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
field: {
|
field: RhfFormField & {
|
||||||
// Label is optional because radioInput doesn't have a label
|
// Label is optional because radioInput doesn't have a label
|
||||||
label?: string;
|
label?: string;
|
||||||
required?: boolean;
|
|
||||||
name?: string;
|
|
||||||
options?: RhfFormField["options"];
|
|
||||||
optionsInputs?: RhfFormField["optionsInputs"];
|
|
||||||
type: RhfFormField["type"];
|
|
||||||
};
|
};
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
} & ValueProps) => {
|
} & ValueProps) => {
|
||||||
const fieldType = field.type;
|
const fieldType = field.type;
|
||||||
const componentConfig = Components[fieldType];
|
const componentConfig = Components[fieldType];
|
||||||
const isObjectiveWithInputValue = (value: any): value is { value: string } => {
|
const isObjectiveWithInputValue = (value: any): value is { value: string } => {
|
||||||
return typeof value === "object" ? "value" in value : false;
|
return typeof value === "object" && value !== null ? "value" in value : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (componentConfig.propsType === "text" || componentConfig.propsType === "boolean") {
|
if (componentConfig.propsType === "text" || componentConfig.propsType === "boolean") {
|
||||||
|
@ -499,6 +518,7 @@ export const ComponentForField = ({
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
value={value}
|
value={value}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
|
placeholder={field.placeholder}
|
||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
);
|
);
|
||||||
|
@ -507,9 +527,33 @@ export const ComponentForField = ({
|
||||||
if (value !== undefined && typeof value !== "string") {
|
if (value !== undefined && typeof value !== "string") {
|
||||||
throw new Error(`${value}: Value is not of type string for field ${field.name}`);
|
throw new Error(`${value}: Value is not of type string for field ${field.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WithLabel field={field} readOnly={readOnly}>
|
<WithLabel field={field} readOnly={readOnly}>
|
||||||
<componentConfig.factory label={field.label} readOnly={readOnly} value={value} setValue={setValue} />
|
<componentConfig.factory
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
label={field.label}
|
||||||
|
readOnly={readOnly}
|
||||||
|
value={value}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentConfig.propsType === "textList") {
|
||||||
|
if (value !== undefined && !(value instanceof Array)) {
|
||||||
|
throw new Error(`${value}: Value is not of type array for ${field.name}`);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<WithLabel field={field} readOnly={readOnly}>
|
||||||
|
<componentConfig.factory
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
label={field.label}
|
||||||
|
readOnly={readOnly}
|
||||||
|
value={value}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -527,6 +571,7 @@ export const ComponentForField = ({
|
||||||
<componentConfig.factory
|
<componentConfig.factory
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
value={value}
|
value={value}
|
||||||
|
placeholder={field.placeholder}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
options={field.options.map((o) => ({ ...o, title: o.label }))}
|
options={field.options.map((o) => ({ ...o, title: o.label }))}
|
||||||
/>
|
/>
|
||||||
|
@ -544,6 +589,7 @@ export const ComponentForField = ({
|
||||||
return (
|
return (
|
||||||
<WithLabel field={field} readOnly>
|
<WithLabel field={field} readOnly>
|
||||||
<componentConfig.factory
|
<componentConfig.factory
|
||||||
|
placeholder={field.placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
value={value}
|
value={value}
|
||||||
setValue={setValue as (value: string[]) => void}
|
setValue={setValue as (value: string[]) => void}
|
||||||
|
@ -566,6 +612,7 @@ export const ComponentForField = ({
|
||||||
return field.options.length ? (
|
return field.options.length ? (
|
||||||
<WithLabel field={field} readOnly>
|
<WithLabel field={field} readOnly>
|
||||||
<componentConfig.factory
|
<componentConfig.factory
|
||||||
|
placeholder={field.placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={value}
|
value={value}
|
||||||
|
@ -579,7 +626,6 @@ export const ComponentForField = ({
|
||||||
throw new Error(`Field ${field.name} does not have a valid propsType`);
|
throw new Error(`Field ${field.name} does not have a valid propsType`);
|
||||||
};
|
};
|
||||||
|
|
||||||
//TODO: ManageBookings: Move it along FormBuilder - Also create a story for it.
|
|
||||||
export const FormBuilderField = ({
|
export const FormBuilderField = ({
|
||||||
field,
|
field,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
@ -594,7 +640,7 @@ export const FormBuilderField = ({
|
||||||
window.form = form;
|
window.form = form;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="reloading mb-4">
|
<div data-form-builder-field-name={field.name} className="reloading mb-4">
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
// Make it a variable
|
// Make it a variable
|
||||||
|
@ -607,7 +653,7 @@ export const FormBuilderField = ({
|
||||||
value={value}
|
value={value}
|
||||||
// Choose b/w disabled and readOnly
|
// Choose b/w disabled and readOnly
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
setValue={(val: any) => {
|
setValue={(val: unknown) => {
|
||||||
onChange(val);
|
onChange(val);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -620,7 +666,6 @@ export const FormBuilderField = ({
|
||||||
if (name !== field.name) {
|
if (name !== field.name) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// console.error(name, field.name, message, "ErrorMesg");
|
|
||||||
|
|
||||||
message = message.replace(/\{[^}]+\}(.*)/, "$1");
|
message = message.replace(/\{[^}]+\}(.*)/, "$1");
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -2,7 +2,14 @@ import { PeriodType, Prisma, SchedulingType } from "@prisma/client";
|
||||||
|
|
||||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||||
import { userSelect } from "@calcom/prisma/selects";
|
import { userSelect } from "@calcom/prisma/selects";
|
||||||
import { CustomInputSchema, EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import {
|
||||||
|
CustomInputSchema,
|
||||||
|
EventTypeMetaDataSchema,
|
||||||
|
customInputSchema,
|
||||||
|
eventTypeBookingFields,
|
||||||
|
} from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
|
import { ensureBookingInputsHaveSystemFields } from "./getEventTypeById";
|
||||||
|
|
||||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||||
|
|
||||||
|
@ -87,7 +94,12 @@ const commons = {
|
||||||
users: [user],
|
users: [user],
|
||||||
hosts: [],
|
hosts: [],
|
||||||
metadata: EventTypeMetaDataSchema.parse({}),
|
metadata: EventTypeMetaDataSchema.parse({}),
|
||||||
bookingFields: eventTypeBookingFields.parse([]),
|
bookingFields: ensureBookingInputsHaveSystemFields({
|
||||||
|
bookingFields: eventTypeBookingFields.parse([]),
|
||||||
|
disableGuests: true,
|
||||||
|
additionalNotesRequired: false,
|
||||||
|
customInputs: customInputSchema.array().parse([]),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const min15Event = {
|
const min15Event = {
|
||||||
|
|
|
@ -49,9 +49,10 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
[EventTypeCustomInputType.PHONE]: BookingFieldType.phone,
|
[EventTypeCustomInputType.PHONE]: BookingFieldType.phone,
|
||||||
};
|
};
|
||||||
|
|
||||||
const systemFields: typeof bookingFields = [
|
// These fields should be added before other user fields
|
||||||
|
const systemBeforeFields: typeof bookingFields = [
|
||||||
{
|
{
|
||||||
label: "Your name",
|
label: "your_name",
|
||||||
type: "name",
|
type: "name",
|
||||||
name: "name",
|
name: "name",
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -65,7 +66,7 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Your email",
|
label: "email_address",
|
||||||
type: "email",
|
type: "email",
|
||||||
name: "email",
|
name: "email",
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -79,7 +80,7 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Location",
|
label: "location",
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
name: "location",
|
name: "location",
|
||||||
editable: "system",
|
editable: "system",
|
||||||
|
@ -106,8 +107,12 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// These fields should be added after other user fields
|
||||||
|
const systemAfterFields: typeof bookingFields = [
|
||||||
{
|
{
|
||||||
label: "Additional Notes",
|
label: "additional_notes",
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
name: "notes",
|
name: "notes",
|
||||||
editable: "system-but-optional",
|
editable: "system-but-optional",
|
||||||
|
@ -121,7 +126,7 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add guests",
|
label: "additional_guests",
|
||||||
type: "multiemail",
|
type: "multiemail",
|
||||||
name: "guests",
|
name: "guests",
|
||||||
editable: "system-but-optional",
|
editable: "system-but-optional",
|
||||||
|
@ -137,7 +142,7 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
//TODO: How to translate in user language?
|
//TODO: How to translate in user language?
|
||||||
label: "Reschedule Reason",
|
label: "reschedule_reason",
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
name: "rescheduleReason",
|
name: "rescheduleReason",
|
||||||
editable: "system",
|
editable: "system",
|
||||||
|
@ -154,16 +159,16 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const missingSystemFields = [];
|
const missingSystemBeforeFields = [];
|
||||||
// Push system fields first if any of them don't exist. We can't simply live without system fields
|
for (const field of systemBeforeFields) {
|
||||||
for (const field of systemFields) {
|
|
||||||
// Only do a push, we must not update existing system fields as user could have modified any property in it,
|
// Only do a push, we must not update existing system fields as user could have modified any property in it,
|
||||||
if (!bookingFields.find((f) => f.name === field.name)) {
|
if (!bookingFields.find((f) => f.name === field.name)) {
|
||||||
missingSystemFields.push(field);
|
missingSystemBeforeFields.push(field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bookingFields = missingSystemFields.concat(bookingFields);
|
bookingFields = missingSystemBeforeFields.concat(bookingFields);
|
||||||
|
|
||||||
// If we are migrating from old system, we need to add custom inputs to the end of the list
|
// If we are migrating from old system, we need to add custom inputs to the end of the list
|
||||||
if (handleMigration) {
|
if (handleMigration) {
|
||||||
customInputs.forEach((input) => {
|
customInputs.forEach((input) => {
|
||||||
|
@ -188,6 +193,16 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const missingSystemAfterFields = [];
|
||||||
|
for (const field of systemAfterFields) {
|
||||||
|
// Only do a push, we must not update existing system fields as user could have modified any property in it,
|
||||||
|
if (!bookingFields.find((f) => f.name === field.name)) {
|
||||||
|
missingSystemAfterFields.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bookingFields = bookingFields.concat(missingSystemAfterFields);
|
||||||
|
|
||||||
return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields);
|
return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -55,11 +55,11 @@ export const EventTypeMetaDataSchema = z
|
||||||
|
|
||||||
export const eventTypeBookingFields = formBuilderFieldsSchema;
|
export const eventTypeBookingFields = formBuilderFieldsSchema;
|
||||||
|
|
||||||
// Real validation happens using getBookingResponsesSchema which requires eventType. Is there a better way to do it?
|
// Validation of user added bookingFields's responses happen using getBookingResponsesSchema which requires eventType.
|
||||||
export const bookingResponses = z.object({
|
export const bookingResponses = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
guests: z.string().optional(),
|
guests: z.array(z.string()).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
location: z
|
location: z
|
||||||
.object({
|
.object({
|
||||||
|
|
Loading…
Reference in New Issue