Feature/ Manage Booking Questions (#6560)
* WIP
* Create Booking Questions builder
* Renaming things
* wip
* wip
* Implement Add Guests and other fixes
* Fixes after testing
* Fix wrong status code 404
* Fixes
* Lint fixes
* Self review comments addressed
* More self review comments addressed
* Feedback from zomars
* BugFixes after testing
* More fixes discovered during review
* Update packages/lib/hooks/useHasPaidPlan.ts
Co-authored-by: Omar López <zomars@me.com>
* More fixes discovered during review
* Update packages/ui/components/form/inputs/Input.tsx
Co-authored-by: Omar López <zomars@me.com>
* More fixes discovered during review
* Update packages/features/bookings/lib/getBookingFields.ts
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
* More PR review fixes
* Hide label using labelSrOnly
* Fix Carinas feedback and implement 2 workflows thingy
* Misc fixes
* Fixes from Loom comments and PR
* Fix a lint errr
* Fix cancellation reason
* Fix regression in edit due to name conflict check
* Update packages/features/form-builder/FormBuilder.tsx
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
* Fix options not set when default value is used
* Restoring reqBody to avoid uneeded conflicts with main
* Type fix
* Update apps/web/components/booking/pages/BookingPage.tsx
Co-authored-by: Omar López <zomars@me.com>
* Update packages/features/form-builder/FormBuilder.tsx
Co-authored-by: Omar López <zomars@me.com>
* Update apps/web/components/booking/pages/BookingPage.tsx
Co-authored-by: Omar López <zomars@me.com>
* Apply suggestions from code review
Co-authored-by: Omar López <zomars@me.com>
* Show fields but mark them disabled
* Apply suggestions from code review
Co-authored-by: Omar López <zomars@me.com>
* More comments
* Fix booking success page crash when a booking doesnt have newly added required fields response
* Dark theme asterisk not visible
* Make location required in zodSchema as was there in production
* Linting
* Remove _metadata.ts files for apps that have config.json
* Revert "Remove _metadata.ts files for apps that have config.json"
This reverts commit d79bdd336c
.
* Fix lint error
* Fix missing condition for samlSPConfig
* Delete unexpectedly added file
* yarn.lock change not required
* fix types
* Make checkboxes rounded
* Fix defaultLabel being stored as label due to SSR rendering
* Shaved 16kb from booking page
* Explicit types for profile
* Show payment value only if price is greater than 0
* Fix type error
* Add back inferred types as they are failing
* Fix duplicate label on number
---------
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
This commit is contained in:
parent
51bf613621
commit
517cfde5b8
|
@ -0,0 +1,24 @@
|
|||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { FiCreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
const BookingDescriptionPayment = (props: { eventType: Parameters<typeof getPaymentAppData>[0] }) => {
|
||||
const paymentAppData = getPaymentAppData(props.eventType);
|
||||
if (!paymentAppData || paymentAppData.price <= 0) return null;
|
||||
|
||||
return (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDescriptionPayment;
|
File diff suppressed because it is too large
Load Diff
|
@ -4,7 +4,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
|
|||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { Controller, useForm, useWatch, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType, LocationObject } from "@calcom/app-store/locations";
|
||||
|
@ -49,16 +49,19 @@ const LocationInput = (props: {
|
|||
defaultValue?: string;
|
||||
}): JSX.Element | null => {
|
||||
const { eventLocationType, locationFormMethods, ...remainingProps } = props;
|
||||
const { control } = useFormContext() as typeof locationFormMethods;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
return (
|
||||
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
return (
|
||||
<PhoneInput
|
||||
<Controller
|
||||
name={eventLocationType.variable}
|
||||
control={locationFormMethods.control}
|
||||
{...remainingProps}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return <PhoneInput onChange={onChange} value={value} {...remainingProps} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,214 +0,0 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import type { CustomInputParsed } from "pages/event-types/[type]";
|
||||
import type { FC } from "react";
|
||||
import type { Control, UseFormRegister } from "react-hook-form";
|
||||
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Label, Select, TextField } from "@calcom/ui";
|
||||
import { FiPlus, FiX } from "@calcom/ui/components/icon";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
value: EventTypeCustomInputType;
|
||||
options?: { label: string; type: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (output: CustomInputParsed) => void;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: CustomInputParsed;
|
||||
}
|
||||
|
||||
type IFormInput = CustomInputParsed;
|
||||
|
||||
/**
|
||||
* Getting a random ID gives us the option to know WHICH field is changed
|
||||
* when the user edits a custom field.
|
||||
* This UUID is only used to check for changes in the UI and not the ID we use in the DB
|
||||
* There is very very very slim chance that this will cause a collision
|
||||
* */
|
||||
const randomId = () => Math.floor(Math.random() * 1000000 + new Date().getTime());
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const inputOptions: OptionTypeBase[] = [
|
||||
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
{
|
||||
value: EventTypeCustomInputType.RADIO,
|
||||
label: t("radio"),
|
||||
},
|
||||
{ value: EventTypeCustomInputType.PHONE, label: t("phone_number") },
|
||||
];
|
||||
|
||||
const { selectedCustomInput } = props;
|
||||
|
||||
const defaultValues = selectedCustomInput
|
||||
? { ...selectedCustomInput, id: selectedCustomInput?.id || randomId() }
|
||||
: {
|
||||
id: randomId(),
|
||||
type: EventTypeCustomInputType.TEXT,
|
||||
};
|
||||
|
||||
const { register, control, getValues } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
||||
onChange={(option) => option && field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label={t("label")}
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||
<TextField
|
||||
label={t("placeholder")}
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
)}
|
||||
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||
<RadioInputHandler control={control} register={register} />
|
||||
)}
|
||||
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="required"
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
|
||||
defaultChecked={selectedCustomInput?.required ?? true}
|
||||
{...register("required")}
|
||||
/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
{t("is_required")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
id="eventTypeId"
|
||||
value={selectedCustomInput?.eventTypeId || -1}
|
||||
{...register("eventTypeId", { valueAsNumber: true })}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="id"
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.onSubmit(getValues());
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RadioInputHandler({
|
||||
register,
|
||||
control,
|
||||
}: {
|
||||
register: UseFormRegister<IFormInput>;
|
||||
control: Control<IFormInput>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { fields, append, remove } = useFieldArray<IFormInput>({
|
||||
control,
|
||||
name: "options",
|
||||
shouldUnregister: true,
|
||||
});
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col ">
|
||||
<Label htmlFor="radio_options">{t("options")}</Label>
|
||||
<ul
|
||||
className="flex max-h-80 w-full flex-col space-y-1 overflow-y-scroll rounded-md bg-gray-50 p-4"
|
||||
ref={animateRef}>
|
||||
<>
|
||||
{fields.map((option, index) => (
|
||||
<li key={`${option.id}`}>
|
||||
<TextField
|
||||
id={option.id}
|
||||
placeholder={t("enter_option", { index: index + 1 })}
|
||||
addOnFilled={false}
|
||||
label={t("option", { index: index + 1 })}
|
||||
labelSrOnly
|
||||
{...register(`options.${index}.label` as const, { required: true })}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={FiX}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={FiPlus}
|
||||
className="!text-sm !font-medium"
|
||||
onClick={() => {
|
||||
append({ label: "", type: "text" });
|
||||
}}>
|
||||
{t("add_an_option")}
|
||||
</Button>
|
||||
</>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomInputTypeForm;
|
|
@ -1,5 +1,5 @@
|
|||
import Link from "next/link";
|
||||
import type { CustomInputParsed, EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
|
@ -8,7 +8,7 @@ import { v5 as uuidv5 } from "uuid";
|
|||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
@ -26,9 +26,7 @@ import {
|
|||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { FiEdit, FiCopy, FiPlus } from "@calcom/ui/components/icon";
|
||||
|
||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||
import { FiEdit, FiCopy } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
|
@ -39,22 +37,11 @@ const generateHashedLink = (id: number) => {
|
|||
return uid;
|
||||
};
|
||||
|
||||
const getRandomId = (length = 8) => {
|
||||
return (
|
||||
-1 *
|
||||
parseInt(
|
||||
Math.ceil(Math.random() * Date.now())
|
||||
.toPrecision(length)
|
||||
.toString()
|
||||
.replace(".", "")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
|
@ -67,21 +54,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
t,
|
||||
};
|
||||
const [previewText, setPreviewText] = useState(getEventName(eventNameObject));
|
||||
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<CustomInputParsed | undefined>(undefined);
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const removeCustom = (index: number) => {
|
||||
formMethods.getValues("customInputs").splice(index, 1);
|
||||
customInputs.splice(index, 1);
|
||||
setCustomInputs([...customInputs]);
|
||||
};
|
||||
|
||||
const replaceEventNamePlaceholder = (eventNameObject: EventNameObjectType, previewEventName: string) =>
|
||||
previewEventName
|
||||
.replace("{Event type title}", eventNameObject.eventType)
|
||||
|
@ -96,11 +72,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
}, [eventType.users, hashedUrl, team?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.customInputs) {
|
||||
setCustomInputs(eventType.customInputs.sort((a, b) => a.id - b.id));
|
||||
}
|
||||
}, [eventType.customInputs]);
|
||||
const toggleGuests = (enabled: boolean) => {
|
||||
const bookingFields = formMethods.getValues("bookingFields");
|
||||
formMethods.setValue(
|
||||
"bookingFields",
|
||||
bookingFields.map((field) => {
|
||||
if (field.name === "guests") {
|
||||
return {
|
||||
...field,
|
||||
hidden: !enabled,
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const eventNamePlaceholder = replaceEventNamePlaceholder(eventNameObject, t("meeting_with_user"));
|
||||
|
||||
|
@ -167,48 +153,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="">
|
||||
<SettingsToggle
|
||||
title={t("additional_inputs")}
|
||||
description={t("additional_input_description")}
|
||||
checked={customInputs.length > 0}
|
||||
onCheckedChange={(e) => {
|
||||
if (e && customInputs.length === 0) {
|
||||
// Push a placeholders
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
} else if (!e) {
|
||||
formMethods.setValue("customInputs", []);
|
||||
}
|
||||
}}>
|
||||
<ul className="my-4 rounded-md border">
|
||||
{customInputs.map((customInput, idx) => (
|
||||
<CustomInputItem
|
||||
key={idx}
|
||||
question={customInput.label}
|
||||
type={customInput.type}
|
||||
required={customInput.required}
|
||||
editOnClick={() => {
|
||||
setSelectedCustomInput(customInput);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
}}
|
||||
deleteOnClick={() => removeCustom(idx)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{customInputs.length > 0 && (
|
||||
<Button
|
||||
StartIcon={FiPlus}
|
||||
color="minimal"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCustomInput(undefined);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
}}>
|
||||
{t("add_input")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
/>
|
||||
<hr />
|
||||
<RequiresConfirmationController
|
||||
seatsEnabled={seatsEnabled}
|
||||
|
@ -216,22 +166,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="disableGuests"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.disableGuests}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("disable_guests")}
|
||||
description={t("disable_guests_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
disabled={seatsEnabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
|
@ -247,22 +181,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="metadata.additionalNotesRequired"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.metadata.additionalNotesRequired}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex space-x-3 ">
|
||||
<SettingsToggle
|
||||
title={t("require_additional_notes")}
|
||||
description={t("require_additional_notes_description")}
|
||||
checked={!!value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
|
@ -363,13 +281,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
formMethods.setValue("disableGuests", true);
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
formMethods.setValue("disableGuests", false);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
|
@ -475,62 +393,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<Controller
|
||||
name="customInputs"
|
||||
control={formMethods.control}
|
||||
defaultValue={customInputs}
|
||||
render={() => (
|
||||
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
Icon={FiPlus}
|
||||
title={t("add_new_custom_input_field")}
|
||||
description={t("this_input_will_shown_booking_this_event")}>
|
||||
<CustomInputTypeForm
|
||||
selectedCustomInput={selectedCustomInput}
|
||||
onSubmit={(values) => {
|
||||
const customInput: CustomInputParsed = {
|
||||
id: getRandomId(),
|
||||
eventTypeId: -1,
|
||||
label: values.label,
|
||||
placeholder: values.placeholder,
|
||||
required: values.required,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
hasToBeCreated: true,
|
||||
};
|
||||
if (selectedCustomInput) {
|
||||
selectedCustomInput.label = customInput.label;
|
||||
selectedCustomInput.placeholder = customInput.placeholder;
|
||||
selectedCustomInput.required = customInput.required;
|
||||
selectedCustomInput.type = customInput.type;
|
||||
selectedCustomInput.options = customInput.options || undefined;
|
||||
selectedCustomInput.hasToBeCreated = false;
|
||||
// Update by id
|
||||
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||
customInputs[inputIndex] = selectedCustomInput;
|
||||
setCustomInputs(customInputs);
|
||||
formMethods.setValue("customInputs", customInputs);
|
||||
} else {
|
||||
const concatted = customInputs.concat({
|
||||
...customInput,
|
||||
options: customInput.options,
|
||||
});
|
||||
console.log(concatted);
|
||||
setCustomInputs(concatted);
|
||||
formMethods.setValue("customInputs", concatted);
|
||||
}
|
||||
|
||||
setSelectedCustomInputModalOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setSelectedCustomInputModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,55 @@
|
|||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import type { z } from "zod";
|
||||
|
||||
async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
type BookingSelect = {
|
||||
description: true;
|
||||
customInputs: true;
|
||||
attendees: {
|
||||
select: {
|
||||
email: true;
|
||||
name: true;
|
||||
};
|
||||
};
|
||||
location: true;
|
||||
smsReminderNumber: true;
|
||||
};
|
||||
|
||||
// Backward Compatibility for booking created before we had managed booking questions
|
||||
function getResponsesFromOldBooking(
|
||||
rawBooking: Prisma.BookingGetPayload<{
|
||||
select: BookingSelect;
|
||||
}>
|
||||
) {
|
||||
const customInputs = rawBooking.customInputs || {};
|
||||
const responses = Object.keys(customInputs).reduce((acc, label) => {
|
||||
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
name: rawBooking.attendees[0].name,
|
||||
email: rawBooking.attendees[0].email,
|
||||
guests: rawBooking.attendees.slice(1).map((attendee) => {
|
||||
return attendee.email;
|
||||
}),
|
||||
notes: rawBooking.description || "",
|
||||
location: {
|
||||
value: rawBooking.location || "",
|
||||
optionValue: rawBooking.location || "",
|
||||
},
|
||||
...responses,
|
||||
};
|
||||
}
|
||||
|
||||
async function getBooking(
|
||||
prisma: PrismaClient,
|
||||
uid: string,
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
|
||||
) {
|
||||
const rawBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
|
@ -9,6 +57,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
|||
startTime: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
responses: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
attendees: {
|
||||
|
@ -20,6 +69,14 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
|||
},
|
||||
});
|
||||
|
||||
if (!rawBooking) {
|
||||
return rawBooking;
|
||||
}
|
||||
|
||||
const booking = getBookingWithResponses(rawBooking, {
|
||||
bookingFields,
|
||||
});
|
||||
|
||||
if (booking) {
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
|
@ -31,4 +88,23 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
|||
|
||||
export type GetBookingType = Prisma.PromiseReturnType<typeof getBooking>;
|
||||
|
||||
export const getBookingWithResponses = <
|
||||
T extends Prisma.BookingGetPayload<{
|
||||
select: BookingSelect & {
|
||||
responses: true;
|
||||
};
|
||||
}>
|
||||
>(
|
||||
booking: T,
|
||||
eventType: {
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
|
||||
}
|
||||
) => {
|
||||
return {
|
||||
...booking,
|
||||
responses: getBookingResponsesPartialSchema({
|
||||
bookingFields: eventType.bookingFields,
|
||||
}).parse(booking.responses || getResponsesFromOldBooking(booking)),
|
||||
};
|
||||
};
|
||||
export default getBooking;
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import type { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const createBooking = async (data: BookingCreateBody) => {
|
||||
const response = await fetch.post<BookingCreateBody, BookingResponse>("/api/book/event", data);
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import type { BookingResponse } from "@lib/types/booking";
|
||||
|
||||
type BookingCreateBodyForMutation = Omit<BookingCreateBody, "location">;
|
||||
const createBooking = async (data: BookingCreateBodyForMutation) => {
|
||||
const response = await fetch.post<BookingCreateBodyForMutation, BookingResponse>("/api/book/event", data);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import type { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||
import type { BookingResponse } from "@lib/types/booking";
|
||||
|
||||
type ExtendedBookingCreateBody = BookingCreateBody & {
|
||||
noEmail?: boolean;
|
||||
|
|
|
@ -2,36 +2,6 @@ import type { Attendee, Booking } from "@prisma/client";
|
|||
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
export type BookingCreateBody = {
|
||||
email: string;
|
||||
end: string;
|
||||
web3Details?: {
|
||||
userWallet: string;
|
||||
userSignature: unknown;
|
||||
};
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
guests?: string[];
|
||||
location: string;
|
||||
name: string;
|
||||
notes?: string;
|
||||
rescheduleUid?: string;
|
||||
recurringEventId?: string;
|
||||
start: string;
|
||||
timeZone: string;
|
||||
user?: string | string[];
|
||||
language: string;
|
||||
bookingUid?: string;
|
||||
customInputs: { label: string; value: string | boolean }[];
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
hasHashedBookingLink: boolean;
|
||||
hashedLink?: string | null;
|
||||
smsReminderNumber?: string;
|
||||
ethSignature?: string;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
paymentUid?: string;
|
||||
attendees: Attendee[];
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import {
|
||||
getDefaultEvent,
|
||||
|
@ -118,10 +119,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
});
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventTypeRaw),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
|
@ -183,7 +184,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
prisma,
|
||||
context.query.rescheduleUid
|
||||
? (context.query.rescheduleUid as string)
|
||||
: (context.query.bookingUid as string)
|
||||
: (context.query.bookingUid as string),
|
||||
eventTypeObject.bookingFields
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,9 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
|||
const session = await getSession({ req });
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const booking = await handleNewBooking(req);
|
||||
const booking = await handleNewBooking(req, {
|
||||
isNotAnApiCall: true,
|
||||
});
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { BookingStatus, WorkflowActions } from "@prisma/client";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { createEvent } from "ics";
|
||||
|
@ -23,6 +23,10 @@ import {
|
|||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import {
|
||||
SystemField,
|
||||
getBookingFieldsWithSystemFields,
|
||||
} from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
@ -42,10 +46,11 @@ import prisma from "@calcom/prisma";
|
|||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||
import { Button, EmailInput, HeadSeo, Label } from "@calcom/ui";
|
||||
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { getBookingWithResponses } from "@lib/getBooking";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import CancelBooking from "@components/booking/CancelBooking";
|
||||
|
@ -195,10 +200,7 @@ export default function Success(props: SuccessProps) {
|
|||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
|
||||
const location: ReturnType<typeof getEventLocationValue> = props.bookingInfo.location
|
||||
? props.bookingInfo.location
|
||||
: // If there is no location set then we default to Cal Video
|
||||
"integrations:daily";
|
||||
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
|
||||
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
props?.bookingInfo?.metadata || {}
|
||||
|
@ -335,12 +337,12 @@ export default function Success(props: SuccessProps) {
|
|||
}
|
||||
return t("emailed_you_and_attendees" + titleSuffix);
|
||||
}
|
||||
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
|
||||
const title = t(
|
||||
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||
);
|
||||
const customInputs = bookingInfo?.customInputs;
|
||||
|
||||
const locationToDisplay = getSuccessPageLocationMessage(
|
||||
locationVideoCallUrl ? locationVideoCallUrl : location,
|
||||
|
@ -348,10 +350,6 @@ export default function Success(props: SuccessProps) {
|
|||
bookingInfo.status
|
||||
);
|
||||
|
||||
const hasSMSAttendeeAction =
|
||||
eventType.workflows.find((workflowEventType) =>
|
||||
workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE)
|
||||
) !== undefined;
|
||||
const providerName = guessEventLocationType(location)?.label;
|
||||
|
||||
return (
|
||||
|
@ -541,63 +539,27 @@ export default function Success(props: SuccessProps) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{customInputs &&
|
||||
Object.keys(customInputs).map((key) => {
|
||||
// This breaks if you have two label that are the same.
|
||||
// TODO: Fix this in another PR
|
||||
const customInput = customInputs[key as keyof typeof customInputs];
|
||||
const eventTypeCustomFound = eventType.customInputs?.find((ci) => ci.label === key);
|
||||
return (
|
||||
<>
|
||||
{eventTypeCustomFound?.type === "RADIO" && (
|
||||
<>
|
||||
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{eventTypeCustomFound.label}
|
||||
</div>
|
||||
<div className="col-span-3 mt-1 mb-2">
|
||||
{eventTypeCustomFound.options &&
|
||||
eventTypeCustomFound.options.map((option) => {
|
||||
const selected = option.label == customInput;
|
||||
return (
|
||||
<div
|
||||
key={option.label}
|
||||
className={classNames(
|
||||
"flex space-x-1",
|
||||
!selected && "text-gray-500"
|
||||
)}>
|
||||
<p>{option.label}</p>
|
||||
<span>{option.label === customInput && "✅"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||
<>
|
||||
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{key}
|
||||
</div>
|
||||
<div className="col-span-3 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
) : (
|
||||
<p>{customInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{bookingInfo?.smsReminderNumber && hasSMSAttendeeAction && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("number_sms_notifications")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p>{bookingInfo.smsReminderNumber}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Object.entries(bookingInfo.responses).map(([name, response]) => {
|
||||
const field = eventType.bookingFields.find((field) => field.name === name);
|
||||
// We show location in the "where" section
|
||||
// We show Booker Name, Emails and guests in Who section
|
||||
// We show notes in additional notes section
|
||||
// We show rescheduleReason at the top
|
||||
if (!field) return null;
|
||||
const isSystemField = SystemField.safeParse(field.name);
|
||||
if (isSystemField.success) return null;
|
||||
|
||||
const label = field.label || t(field.defaultLabel || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{label}</Label>
|
||||
{/* Might be a good idea to use the readonly variant of respective components here */}
|
||||
<div className="col-span-3 mt-1 mb-2">{response.toString()}</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(!needsConfirmation || !userIsOwner) &&
|
||||
|
@ -927,6 +889,8 @@ const getEventTypesFromDB = async (id: number) => {
|
|||
locations: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
disableGuests: true,
|
||||
owner: {
|
||||
select: userSelect,
|
||||
},
|
||||
|
@ -951,6 +915,7 @@ const getEventTypesFromDB = async (id: number) => {
|
|||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
|
@ -973,6 +938,7 @@ const getEventTypesFromDB = async (id: number) => {
|
|||
return {
|
||||
isDynamic: false,
|
||||
...eventType,
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
@ -1017,7 +983,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
if (!parsedQuery.success) return { notFound: true };
|
||||
const { uid, email, eventTypeSlug, cancel } = parsedQuery.data;
|
||||
|
||||
const bookingInfo = await prisma.booking.findFirst({
|
||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
|
@ -1035,6 +1001,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
status: true,
|
||||
metadata: true,
|
||||
cancellationReason: true,
|
||||
responses: true,
|
||||
rejectionReason: true,
|
||||
user: {
|
||||
select: {
|
||||
|
@ -1059,27 +1026,28 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!bookingInfo) {
|
||||
if (!bookingInfoRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
||||
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
|
||||
|
||||
const eventTypeRaw = !bookingInfo.eventTypeId
|
||||
const eventTypeRaw = !bookingInfoRaw.eventTypeId
|
||||
? getDefaultEvent(eventTypeSlug || "")
|
||||
: await getEventTypesFromDB(bookingInfo.eventTypeId);
|
||||
: await getEventTypesFromDB(bookingInfoRaw.eventTypeId);
|
||||
if (!eventTypeRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw);
|
||||
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
||||
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
|
||||
|
||||
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
|
||||
? eventTypeRaw.hosts.map((host) => host.user)
|
||||
: eventTypeRaw.users;
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
@ -71,6 +71,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import type { PeriodType } from "@prisma/client";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
|
@ -16,6 +16,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
@ -86,6 +87,7 @@ export type FormValues = {
|
|||
bookingLimits?: BookingLimit;
|
||||
hosts: { userId: number }[];
|
||||
hostsFixed: { userId: number }[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
};
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
|
@ -178,48 +180,59 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||
delete metadata.config?.useHostSchedulesForTeamEvent;
|
||||
}
|
||||
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: eventType.title,
|
||||
locations: eventType.locations || [],
|
||||
recurringEvent: eventType.recurringEvent || null,
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
length: eventType.length,
|
||||
hidden: eventType.hidden,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
metadata,
|
||||
hosts: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => !host.isFixed)
|
||||
: eventType.users
|
||||
.filter(() => eventType.schedulingType === SchedulingType.ROUND_ROBIN)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
hostsFixed: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => host.isFixed)
|
||||
: eventType.users
|
||||
.filter(() => eventType.schedulingType === SchedulingType.COLLECTIVE)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
const defaultValues = {
|
||||
title: eventType.title,
|
||||
locations: eventType.locations || [],
|
||||
recurringEvent: eventType.recurringEvent || null,
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
length: eventType.length,
|
||||
hidden: eventType.hidden,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
bookingFields: eventType.bookingFields,
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
metadata,
|
||||
hosts: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => !host.isFixed)
|
||||
: eventType.users
|
||||
.filter(() => eventType.schedulingType === SchedulingType.ROUND_ROBIN)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
hostsFixed: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => host.isFixed)
|
||||
: eventType.users
|
||||
.filter(() => eventType.schedulingType === SchedulingType.COLLECTIVE)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
} as const;
|
||||
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
// Length if string, is converted to a number or it can be a number
|
||||
// Make it optional because it's not submitted from all tabs of the page
|
||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
bookingFields: eventTypeBookingFields,
|
||||
})
|
||||
// TODO: Add schema for other fields later.
|
||||
.passthrough()
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!formMethods.formState.isDirty) {
|
||||
//TODO: What's the best way to sync the form with backend
|
||||
formMethods.setValue("bookingFields", defaultValues.bookingFields);
|
||||
}
|
||||
}, [defaultValues]);
|
||||
|
||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||
const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0;
|
||||
let numberOfActiveApps = 0;
|
||||
|
@ -342,13 +355,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||
};
|
||||
|
||||
const EventTypePageWrapper = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { data, isLoading } = trpc.viewer.eventTypes.get.useQuery(
|
||||
{ id: props.type },
|
||||
{
|
||||
initialData: props.initialData,
|
||||
}
|
||||
);
|
||||
|
||||
const { data, isLoading } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
|
||||
if (isLoading || !data) return null;
|
||||
return <EventTypePage {...data} />;
|
||||
};
|
||||
|
@ -391,9 +398,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { privacyFilteredLocations } from "@calcom/core/location";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
@ -78,6 +79,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
availability: true,
|
||||
description: true,
|
||||
length: true,
|
||||
disableGuests: true,
|
||||
schedulingType: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
|
@ -96,12 +98,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
slotInterval: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
bookingFields: true,
|
||||
customInputs: true,
|
||||
schedule: {
|
||||
select: {
|
||||
timeZone: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
|
@ -163,7 +177,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid);
|
||||
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject));
|
||||
}
|
||||
|
||||
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
|
||||
|
|
|
@ -2,9 +2,10 @@ import type { GetServerSidePropsContext } from "next";
|
|||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { GetBookingType } from "@lib/getBooking";
|
||||
|
@ -13,6 +14,8 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
|||
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
|
@ -22,6 +25,7 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
|||
TeamBookingPage.isThemeSupported = true;
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||
|
@ -55,6 +59,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
schedulingType: true,
|
||||
bookingFields: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
|
@ -92,12 +97,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
||||
locations: privacyFilteredLocations((eventTypeRaw.locations || []) as LocationObject[]),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
metadata: EventTypeMetaDataSchema.parse(e.metadata || {}),
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||
|
@ -114,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (context.query.rescheduleUid) {
|
||||
booking = await getBooking(prisma, context.query.rescheduleUid as string);
|
||||
booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields);
|
||||
}
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
|
@ -128,6 +134,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
profile: {
|
||||
...eventTypeObject.team,
|
||||
// FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking.
|
||||
|
|
|
@ -90,6 +90,8 @@ test("add webhook & test that creating an event triggers a webhook call", async
|
|||
timeZone: "[redacted/dynamic]",
|
||||
language: "[redacted/dynamic]",
|
||||
},
|
||||
responses: { email: "test@example.com", name: "Test Testson" },
|
||||
userFieldsResponses: {},
|
||||
attendees: [
|
||||
{
|
||||
email: "test@example.com",
|
||||
|
|
|
@ -1599,10 +1599,18 @@
|
|||
"under_maintenance": "Down for maintenance",
|
||||
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
|
||||
"event_type_seats": "{{numberOfSeats}} seats",
|
||||
"booking_questions_title": "Booking questions",
|
||||
"booking_questions_description": "Customize the questions asked on the booking page",
|
||||
"add_a_booking_question": "Add a question",
|
||||
"duplicate_email": "Email is duplicate",
|
||||
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
|
||||
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
|
||||
"booking_confirmation_failed": "Booking confirmation failed",
|
||||
"form_builder_field_already_exists": "A field with this name already exists",
|
||||
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
|
||||
"form_builder_system_field_cant_delete": "This system field can't be removed.",
|
||||
"form_builder_system_field_cant_toggle": "This system field can't be toggled.",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_member": "Team member",
|
||||
"a_routing_form": "A Routing Form",
|
||||
|
|
|
@ -44,6 +44,8 @@ const settings: Settings = {
|
|||
};
|
||||
|
||||
// react-query-builder types have missing type property on Widget
|
||||
//TODO: Reuse FormBuilder Components - FormBuilder components are built considering Cal.com design system and coding guidelines. But when awesome-query-builder renders these components, it passes its own props which are different from what our Components expect.
|
||||
// So, a mapper should be written here that maps the props provided by awesome-query-builder to the props that our components expect.
|
||||
const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string } } = {
|
||||
...BasicConfig.widgets,
|
||||
text: {
|
||||
|
@ -90,6 +92,7 @@ const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string
|
|||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
// TODO: Use EmailField component for Routing Form Email field
|
||||
return <TextWidget type="email" {...props} />;
|
||||
},
|
||||
},
|
||||
|
|
|
@ -4,19 +4,73 @@ import type {
|
|||
ButtonProps,
|
||||
ConjsProps,
|
||||
FieldProps,
|
||||
NumberWidgetProps,
|
||||
ProviderProps,
|
||||
SelectWidgetProps,
|
||||
TextWidgetProps,
|
||||
} from "react-awesome-query-builder";
|
||||
|
||||
import { Button as CalButton, SelectWithValidation as Select, TextArea, TextField } from "@calcom/ui";
|
||||
import { Button as CalButton, SelectWithValidation as Select, TextField } from "@calcom/ui";
|
||||
import { FiTrash, FiPlus } from "@calcom/ui/components/icon";
|
||||
|
||||
// import { mapListValues } from "../../../../utils/stuff";
|
||||
export type CommonProps<
|
||||
TVal extends
|
||||
| string
|
||||
| boolean
|
||||
| string[]
|
||||
| {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
}
|
||||
> = {
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
value: TVal;
|
||||
setValue: (value: TVal) => void;
|
||||
/**
|
||||
* required and other validations are supported using zodResolver from react-hook-form
|
||||
*/
|
||||
// required?: boolean;
|
||||
};
|
||||
|
||||
const TextAreaWidget = (props: TextWidgetProps) => {
|
||||
const { value, setValue, readonly, placeholder, maxLength, customProps, ...remainingProps } = props;
|
||||
export type SelectLikeComponentProps<
|
||||
TVal extends
|
||||
| string
|
||||
| string[]
|
||||
| {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
} = string
|
||||
> = {
|
||||
options: {
|
||||
label: string;
|
||||
value: TVal extends (infer P)[]
|
||||
? P
|
||||
: TVal extends {
|
||||
value: string;
|
||||
}
|
||||
? TVal["value"]
|
||||
: TVal;
|
||||
}[];
|
||||
} & CommonProps<TVal>;
|
||||
|
||||
export type SelectLikeComponentPropsRAQB<TVal extends string | string[] = string> = {
|
||||
listValues: { title: string; value: TVal extends (infer P)[] ? P : TVal }[];
|
||||
} & CommonProps<TVal>;
|
||||
|
||||
export type TextLikeComponentProps<TVal extends string | string[] | boolean = string> = CommonProps<TVal> & {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type TextLikeComponentPropsRAQB<TVal extends string | boolean = string> =
|
||||
TextLikeComponentProps<TVal> & {
|
||||
customProps?: object;
|
||||
type?: "text" | "number" | "email" | "tel";
|
||||
maxLength?: number;
|
||||
noLabel?: boolean;
|
||||
};
|
||||
|
||||
const TextAreaWidget = (props: TextLikeComponentPropsRAQB) => {
|
||||
const { value, setValue, readOnly, placeholder, maxLength, customProps, ...remainingProps } = props;
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
|
@ -25,23 +79,30 @@ const TextAreaWidget = (props: TextWidgetProps) => {
|
|||
|
||||
const textValue = value || "";
|
||||
return (
|
||||
<TextArea
|
||||
<textarea
|
||||
value={textValue}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
disabled={readOnly}
|
||||
onChange={onChange}
|
||||
maxLength={maxLength}
|
||||
className="dark:border-darkgray-300 flex flex-grow border-gray-300 text-sm dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
className="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"
|
||||
{...customProps}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TextWidget = (props: TextWidgetProps & { type?: string }) => {
|
||||
const { value, setValue, readonly, placeholder, customProps, ...remainingProps } = props;
|
||||
let { type } = props;
|
||||
type = type || "text";
|
||||
const TextWidget = (props: TextLikeComponentPropsRAQB) => {
|
||||
const {
|
||||
value,
|
||||
noLabel,
|
||||
setValue,
|
||||
readOnly,
|
||||
placeholder,
|
||||
customProps,
|
||||
type = "text",
|
||||
...remainingProps
|
||||
} = props;
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setValue(val);
|
||||
|
@ -51,10 +112,11 @@ const TextWidget = (props: TextWidgetProps & { type?: string }) => {
|
|||
<TextField
|
||||
containerClassName="w-full"
|
||||
type={type}
|
||||
className="dark:border-darkgray-300 flex flex-grow border-gray-300 text-sm dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
className="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"
|
||||
value={textValue}
|
||||
labelSrOnly={noLabel}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
disabled={readOnly}
|
||||
onChange={onChange}
|
||||
{...remainingProps}
|
||||
{...customProps}
|
||||
|
@ -62,12 +124,13 @@ const TextWidget = (props: TextWidgetProps & { type?: string }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function NumberWidget({ value, setValue, ...remainingProps }: NumberWidgetProps) {
|
||||
function NumberWidget({ value, setValue, ...remainingProps }: TextLikeComponentPropsRAQB) {
|
||||
return (
|
||||
<TextField
|
||||
type="number"
|
||||
labelSrOnly={remainingProps.noLabel}
|
||||
containerClassName="w-full"
|
||||
className="dark:border-darkgray-300 mt-0 border-gray-300 text-sm dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
className="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"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
|
@ -82,10 +145,7 @@ const MultiSelectWidget = ({
|
|||
setValue,
|
||||
value,
|
||||
...remainingProps
|
||||
}: Omit<SelectWidgetProps, "value"> & {
|
||||
listValues: { title: string; value: string }[];
|
||||
value?: string[];
|
||||
}) => {
|
||||
}: SelectLikeComponentPropsRAQB<string[]>) => {
|
||||
//TODO: Use Select here.
|
||||
//TODO: Let's set listValue itself as label and value instead of using title.
|
||||
if (!listValues) {
|
||||
|
@ -108,20 +168,14 @@ const MultiSelectWidget = ({
|
|||
}}
|
||||
defaultValue={defaultValue}
|
||||
isMulti={true}
|
||||
isDisabled={remainingProps.readOnly}
|
||||
options={selectItems}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function SelectWidget({
|
||||
listValues,
|
||||
setValue,
|
||||
value,
|
||||
...remainingProps
|
||||
}: SelectWidgetProps & {
|
||||
listValues: { title: string; value: string }[];
|
||||
}) {
|
||||
function SelectWidget({ listValues, setValue, value, ...remainingProps }: SelectLikeComponentPropsRAQB) {
|
||||
if (!listValues) {
|
||||
return null;
|
||||
}
|
||||
|
@ -142,6 +196,7 @@ function SelectWidget({
|
|||
}
|
||||
setValue(item.value);
|
||||
}}
|
||||
isDisabled={remainingProps.readOnly}
|
||||
defaultValue={defaultValue}
|
||||
options={selectItems}
|
||||
{...remainingProps}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { Info } from "./Info";
|
||||
|
||||
export function CustomInputs(props: { calEvent: CalendarEvent }) {
|
||||
const { customInputs } = props.calEvent;
|
||||
if (!customInputs) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(customInputs).map((key) =>
|
||||
customInputs[key] !== "" ? (
|
||||
<Info key={key} label={key} description={`${customInputs[key]}`} withSpacer />
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { Info } from "./Info";
|
||||
|
||||
export function UserFieldsResponses(props: { calEvent: CalendarEvent }) {
|
||||
const { customInputs, userFieldsResponses } = props.calEvent;
|
||||
const responses = userFieldsResponses || customInputs;
|
||||
if (!responses) return null;
|
||||
return (
|
||||
<>
|
||||
{Object.keys(responses).map((key) =>
|
||||
responses[key] !== "" ? (
|
||||
<Info key={key} label={key} description={`${responses[key]}`} withSpacer />
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -2,7 +2,7 @@ export { BaseEmailHtml } from "./BaseEmailHtml";
|
|||
export { V2BaseEmailHtml } from "./V2BaseEmailHtml";
|
||||
export { CallToAction } from "./CallToAction";
|
||||
export { CallToActionTable } from "./CallToActionTable";
|
||||
export { CustomInputs } from "./CustomInputs";
|
||||
export { UserFieldsResponses } from "./UserFieldsResponses";
|
||||
export { Info } from "./Info";
|
||||
export { LinkIcon } from "./LinkIcon";
|
||||
export { LocationInfo } from "./LocationInfo";
|
||||
|
|
|
@ -5,13 +5,13 @@ import type { AppsStatus as AppsStatusType, CalendarEvent, Person } from "@calco
|
|||
|
||||
import {
|
||||
BaseEmailHtml,
|
||||
CustomInputs,
|
||||
Info,
|
||||
LocationInfo,
|
||||
ManageLink,
|
||||
WhenInfo,
|
||||
WhoInfo,
|
||||
AppsStatus,
|
||||
UserFieldsResponses,
|
||||
} from "../components";
|
||||
|
||||
export const BaseScheduledEmail = (
|
||||
|
@ -73,7 +73,7 @@ export const BaseScheduledEmail = (
|
|||
<Info label={t("description")} description={props.calEvent.description} withSpacer />
|
||||
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
|
||||
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
|
||||
<CustomInputs calEvent={props.calEvent} />
|
||||
<UserFieldsResponses calEvent={props.calEvent} />
|
||||
</BaseEmailHtml>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,332 @@
|
|||
import type { EventTypeCustomInput, EventType, Prisma, Workflow } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import {
|
||||
BookingFieldType,
|
||||
customInputSchema,
|
||||
eventTypeBookingFields,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
|
||||
|
||||
export const getSmsReminderNumberField = () =>
|
||||
({
|
||||
name: SMS_REMINDER_NUMBER_FIELD,
|
||||
type: "phone",
|
||||
defaultLabel: "number_sms_notifications",
|
||||
defaultPlaceholder: "enter_phone_number",
|
||||
editable: "system",
|
||||
} as const);
|
||||
|
||||
export const getSmsReminderNumberSource = ({
|
||||
workflowId,
|
||||
isSmsReminderNumberRequired,
|
||||
}: {
|
||||
workflowId: Workflow["id"];
|
||||
isSmsReminderNumberRequired: boolean;
|
||||
}) => ({
|
||||
id: "" + workflowId,
|
||||
type: "workflow",
|
||||
label: "Workflow",
|
||||
fieldRequired: isSmsReminderNumberRequired,
|
||||
editUrl: `/workflows/${workflowId}`,
|
||||
});
|
||||
|
||||
type Fields = z.infer<typeof eventTypeBookingFields>;
|
||||
|
||||
const EventTypeCustomInputType = {
|
||||
TEXT: "TEXT",
|
||||
TEXTLONG: "TEXTLONG",
|
||||
NUMBER: "NUMBER",
|
||||
BOOL: "BOOL",
|
||||
RADIO: "RADIO",
|
||||
PHONE: "PHONE",
|
||||
} as const;
|
||||
|
||||
export const SystemField = z.enum([
|
||||
"name",
|
||||
"email",
|
||||
"location",
|
||||
"notes",
|
||||
"guests",
|
||||
"rescheduleReason",
|
||||
"smsReminderNumber",
|
||||
]);
|
||||
|
||||
export const SystemFieldsEditability: Record<z.infer<typeof SystemField>, Fields[number]["editable"]> = {
|
||||
name: "system",
|
||||
email: "system",
|
||||
location: "system",
|
||||
notes: "system-but-optional",
|
||||
guests: "system-but-optional",
|
||||
rescheduleReason: "system",
|
||||
smsReminderNumber: "system",
|
||||
};
|
||||
|
||||
/**
|
||||
* This fn is the key to ensure on the fly mapping of customInputs to bookingFields and ensuring that all the systems fields are present and correctly ordered in bookingFields
|
||||
*/
|
||||
export const getBookingFieldsWithSystemFields = ({
|
||||
bookingFields,
|
||||
disableGuests,
|
||||
customInputs,
|
||||
metadata,
|
||||
workflows,
|
||||
}: {
|
||||
bookingFields: Fields | EventType["bookingFields"];
|
||||
disableGuests: boolean;
|
||||
customInputs: EventTypeCustomInput[] | z.infer<typeof customInputSchema>[];
|
||||
metadata: EventType["metadata"] | z.infer<typeof EventTypeMetaDataSchema>;
|
||||
workflows: Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true;
|
||||
steps: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>["workflows"];
|
||||
}) => {
|
||||
const parsedMetaData = EventTypeMetaDataSchema.parse(metadata || {});
|
||||
const parsedBookingFields = eventTypeBookingFields.parse(bookingFields || []);
|
||||
const parsedCustomInputs = customInputSchema.array().parse(customInputs || []);
|
||||
workflows = workflows || [];
|
||||
return ensureBookingInputsHaveSystemFields({
|
||||
bookingFields: parsedBookingFields,
|
||||
disableGuests,
|
||||
additionalNotesRequired: parsedMetaData?.additionalNotesRequired || false,
|
||||
customInputs: parsedCustomInputs,
|
||||
workflows,
|
||||
});
|
||||
};
|
||||
|
||||
export const ensureBookingInputsHaveSystemFields = ({
|
||||
bookingFields,
|
||||
disableGuests,
|
||||
additionalNotesRequired,
|
||||
customInputs,
|
||||
workflows,
|
||||
}: {
|
||||
bookingFields: Fields;
|
||||
disableGuests: boolean;
|
||||
additionalNotesRequired: boolean;
|
||||
customInputs: z.infer<typeof customInputSchema>[];
|
||||
workflows: Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true;
|
||||
steps: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>["workflows"];
|
||||
}) => {
|
||||
// If bookingFields is set already, the migration is done.
|
||||
const handleMigration = !bookingFields.length;
|
||||
const CustomInputTypeToFieldType = {
|
||||
[EventTypeCustomInputType.TEXT]: BookingFieldType.text,
|
||||
[EventTypeCustomInputType.TEXTLONG]: BookingFieldType.textarea,
|
||||
[EventTypeCustomInputType.NUMBER]: BookingFieldType.number,
|
||||
[EventTypeCustomInputType.BOOL]: BookingFieldType.boolean,
|
||||
[EventTypeCustomInputType.RADIO]: BookingFieldType.radio,
|
||||
[EventTypeCustomInputType.PHONE]: BookingFieldType.phone,
|
||||
};
|
||||
|
||||
const smsNumberSources = [] as NonNullable<(typeof bookingFields)[number]["sources"]>;
|
||||
workflows.forEach((workflow) => {
|
||||
workflow.workflow.steps.forEach((step) => {
|
||||
if (step.action === "SMS_ATTENDEE") {
|
||||
const workflowId = workflow.workflow.id;
|
||||
smsNumberSources.push(
|
||||
getSmsReminderNumberSource({
|
||||
workflowId,
|
||||
isSmsReminderNumberRequired: !!step.numberRequired,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// These fields should be added before other user fields
|
||||
const systemBeforeFields: typeof bookingFields = [
|
||||
{
|
||||
defaultLabel: "your_name",
|
||||
defaultPlaceholder: "example_name",
|
||||
type: "name",
|
||||
name: "name",
|
||||
required: true,
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "email_address",
|
||||
defaultPlaceholder: "you@example.com",
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: true,
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "location",
|
||||
type: "radioInput",
|
||||
name: "location",
|
||||
required: false,
|
||||
// Populated on the fly from locations. I don't want to duplicate storing locations and instead would like to be able to refer to locations in eventType.
|
||||
// options: `eventType.locations`
|
||||
optionsInputs: {
|
||||
attendeeInPerson: {
|
||||
type: "address",
|
||||
required: true,
|
||||
placeholder: "",
|
||||
},
|
||||
phone: {
|
||||
type: "phone",
|
||||
required: true,
|
||||
placeholder: "",
|
||||
},
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Backward Compatibility for SMS Reminder Number
|
||||
if (smsNumberSources.length) {
|
||||
systemBeforeFields.push({
|
||||
...getSmsReminderNumberField(),
|
||||
sources: smsNumberSources,
|
||||
});
|
||||
}
|
||||
|
||||
// These fields should be added after other user fields
|
||||
const systemAfterFields: typeof bookingFields = [
|
||||
{
|
||||
defaultLabel: "additional_notes",
|
||||
type: "textarea",
|
||||
name: "notes",
|
||||
required: additionalNotesRequired,
|
||||
defaultPlaceholder: "share_additional_notes",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "additional_guests",
|
||||
type: "multiemail",
|
||||
name: "guests",
|
||||
required: false,
|
||||
hidden: disableGuests,
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "reschedule_reason",
|
||||
type: "textarea",
|
||||
name: "rescheduleReason",
|
||||
defaultPlaceholder: "reschedule_placeholder",
|
||||
required: false,
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const missingSystemBeforeFields = [];
|
||||
for (const field of systemBeforeFields) {
|
||||
// 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)) {
|
||||
missingSystemBeforeFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
bookingFields = missingSystemBeforeFields.concat(bookingFields);
|
||||
|
||||
// Backward Compatibility: If we are migrating from old system, we need to map `customInputs` to `bookingFields`
|
||||
if (handleMigration) {
|
||||
customInputs.forEach((input) => {
|
||||
bookingFields.push({
|
||||
label: input.label,
|
||||
editable: "user",
|
||||
// Custom Input's slugified label was being used as query param for prefilling. So, make that the name of the field
|
||||
name: slugify(input.label),
|
||||
placeholder: input.placeholder,
|
||||
type: CustomInputTypeToFieldType[input.type],
|
||||
required: input.required,
|
||||
options: input.options
|
||||
? input.options.map((o) => {
|
||||
return {
|
||||
...o,
|
||||
// Send the label as the value without any trimming or lowercase as this is what customInput are doing. It maintains backward compatibility
|
||||
value: o.label,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
bookingFields = bookingFields.map((field) => {
|
||||
const foundEditableMap = SystemFieldsEditability[field.name as keyof typeof SystemFieldsEditability];
|
||||
if (!foundEditableMap) {
|
||||
return field;
|
||||
}
|
||||
// Ensure that system fields editability, even if modified to something else in DB(accidentally), get's reset to what's in the code.
|
||||
return {
|
||||
...field,
|
||||
editable: foundEditableMap,
|
||||
};
|
||||
});
|
||||
|
||||
return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields);
|
||||
};
|
|
@ -0,0 +1,188 @@
|
|||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import z from "zod";
|
||||
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import { bookingResponses } from "@calcom/prisma/zod-utils";
|
||||
|
||||
type EventType = Parameters<typeof preprocess>[0]["eventType"];
|
||||
export const getBookingResponsesPartialSchema = (eventType: EventType) => {
|
||||
const schema = bookingResponses.unwrap().partial().and(z.record(z.any()));
|
||||
|
||||
return preprocess({ schema, eventType, isPartialSchema: true });
|
||||
};
|
||||
|
||||
// Should be used when we know that not all fields responses are present
|
||||
// - Can happen when we are parsing the prefill query string
|
||||
// - Can happen when we are parsing a booking's responses (which was created before we added a new required field)
|
||||
export default function getBookingResponsesSchema(eventType: EventType) {
|
||||
const schema = bookingResponses.and(z.record(z.any()));
|
||||
return preprocess({ schema, eventType, isPartialSchema: false });
|
||||
}
|
||||
|
||||
// TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder
|
||||
// It allows anyone using FormBuilder to get the same preprocessing automatically
|
||||
function preprocess<T extends z.ZodType>({
|
||||
schema,
|
||||
eventType,
|
||||
isPartialSchema,
|
||||
}: {
|
||||
schema: T;
|
||||
isPartialSchema: boolean;
|
||||
eventType: {
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
|
||||
};
|
||||
}): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> {
|
||||
const preprocessed = z.preprocess(
|
||||
(responses) => {
|
||||
const parsedResponses = z.record(z.any()).nullable().parse(responses) || {};
|
||||
const newResponses = {} as typeof parsedResponses;
|
||||
eventType.bookingFields.forEach((field) => {
|
||||
const value = parsedResponses[field.name];
|
||||
if (value === undefined) {
|
||||
// If there is no response for the field, then we don't need to do any processing
|
||||
return;
|
||||
}
|
||||
// Turn a boolean in string to a real boolean
|
||||
if (field.type === "boolean") {
|
||||
newResponses[field.name] = value === "true" || value === true;
|
||||
}
|
||||
// Make sure that the value is an array
|
||||
else if (field.type === "multiemail" || field.type === "checkbox" || field.type === "multiselect") {
|
||||
newResponses[field.name] = value instanceof Array ? value : [value];
|
||||
}
|
||||
// Parse JSON
|
||||
else if (field.type === "radioInput" && typeof value === "string") {
|
||||
let parsedValue = {
|
||||
optionValue: "",
|
||||
value: "",
|
||||
};
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch (e) {}
|
||||
newResponses[field.name] = parsedValue;
|
||||
} else {
|
||||
newResponses[field.name] = value;
|
||||
}
|
||||
});
|
||||
return newResponses;
|
||||
},
|
||||
schema.superRefine((responses, ctx) => {
|
||||
eventType.bookingFields.forEach((bookingField) => {
|
||||
const value = responses[bookingField.name];
|
||||
const stringSchema = z.string();
|
||||
const emailSchema = isPartialSchema ? z.string() : z.string().email();
|
||||
const phoneSchema = isPartialSchema
|
||||
? z.string()
|
||||
: z.string().refine((val) => 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 isRequired = bookingField.required;
|
||||
if ((isPartialSchema || !isRequired) && value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRequired && !isPartialSchema && !value)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
|
||||
|
||||
if (bookingField.type === "email") {
|
||||
// Email RegExp to validate if the input is a valid email
|
||||
if (!emailSchema.safeParse(value).success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: m("email_validation_error"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookingField.type === "multiemail") {
|
||||
const emailsParsed = emailSchema.array().safeParse(value);
|
||||
if (!emailsParsed.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: m("email_validation_error"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const emails = emailsParsed.data;
|
||||
emails.sort().some((item, i) => {
|
||||
if (item === emails[i + 1]) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("duplicate_email") });
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (bookingField.type === "phone") {
|
||||
if (!phoneSchema.safeParse(value).success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookingField.type === "boolean") {
|
||||
const schema = z.boolean();
|
||||
if (!schema.safeParse(value).success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookingField.type === "radioInput") {
|
||||
if (bookingField.optionsInputs) {
|
||||
const optionValue = value?.optionValue;
|
||||
const optionField = bookingField.optionsInputs[value?.value];
|
||||
const typeOfOptionInput = optionField?.type;
|
||||
if (
|
||||
// Either the field is required or there is a radio selected, we need to check if the optionInput is required or not.
|
||||
(isRequired || value?.value) &&
|
||||
optionField?.required &&
|
||||
!optionValue
|
||||
) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("error_required_field") });
|
||||
}
|
||||
|
||||
if (optionValue) {
|
||||
// `typeOfOptionInput` can be any of the main types. So, we the same validations should run for `optionValue`
|
||||
if (typeOfOptionInput === "phone") {
|
||||
if (!phoneSchema.safeParse(optionValue).success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
["address", "text", "select", "name", "number", "radio", "textarea"].includes(bookingField.type)
|
||||
) {
|
||||
const schema = stringSchema;
|
||||
if (!schema.safeParse(value).success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Can't parse unknown booking field type: ${bookingField.type}`);
|
||||
});
|
||||
})
|
||||
);
|
||||
if (isPartialSchema) {
|
||||
// Query Params can be completely invalid, try to preprocess as much of it in correct format but in worst case simply don't prefill instead of crashing
|
||||
return preprocessed.catch(() => {
|
||||
console.error("Failed to preprocess query params, prefilling will be skipped");
|
||||
return {};
|
||||
});
|
||||
}
|
||||
return preprocessed;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import type { App, Credential, EventTypeCustomInput, Prisma } from "@prisma/client";
|
||||
import { BookingStatus, SchedulingType, WebhookTriggerEvents, WorkflowMethods } from "@prisma/client";
|
||||
import type { App, Credential, EventTypeCustomInput } from "@prisma/client";
|
||||
import { BookingStatus, SchedulingType, WebhookTriggerEvents, WorkflowMethods, Prisma } from "@prisma/client";
|
||||
import async from "async";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { cloneDeep } from "lodash";
|
||||
|
@ -28,6 +28,7 @@ import {
|
|||
sendScheduledEmails,
|
||||
sendScheduledSeatsEmails,
|
||||
} from "@calcom/emails";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
|
@ -43,9 +44,12 @@ import logger from "@calcom/lib/logger";
|
|||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import { checkBookingLimits, getLuckyUser } from "@calcom/lib/server";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma, { userSelect } from "@calcom/prisma";
|
||||
import type { bookingCreateSchemaLegacyPropsForApi } from "@calcom/prisma/zod-utils";
|
||||
import {
|
||||
bookingCreateBodySchemaForApi,
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
extendedBookingCreateBody,
|
||||
|
@ -58,6 +62,7 @@ import type { WorkingHours } from "@calcom/types/schedule";
|
|||
|
||||
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
|
||||
import sendPayload from "../../webhooks/lib/sendPayload";
|
||||
import getBookingResponsesSchema from "./getBookingResponsesSchema";
|
||||
|
||||
const translator = short();
|
||||
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||
|
@ -162,6 +167,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
select: {
|
||||
id: true,
|
||||
customInputs: true,
|
||||
disableGuests: true,
|
||||
users: userSelect,
|
||||
team: {
|
||||
select: {
|
||||
|
@ -169,6 +175,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
name: true,
|
||||
},
|
||||
},
|
||||
bookingFields: true,
|
||||
title: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
|
@ -228,8 +235,9 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
...eventType,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
customInputs: customInputSchema.array().parse(eventType.customInputs),
|
||||
customInputs: customInputSchema.array().parse(eventType.customInputs || []),
|
||||
locations: (eventType.locations ?? []) as LocationObject[],
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -306,33 +314,146 @@ async function ensureAvailableUsers(
|
|||
return availableUsers;
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
function getBookingData({
|
||||
req,
|
||||
isNotAnApiCall,
|
||||
eventType,
|
||||
}: {
|
||||
req: NextApiRequest;
|
||||
isNotAnApiCall: boolean;
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
|
||||
}) {
|
||||
const bookingDataSchema = isNotAnApiCall
|
||||
? extendedBookingCreateBody.merge(
|
||||
z.object({
|
||||
responses: getBookingResponsesSchema({
|
||||
bookingFields: eventType.bookingFields,
|
||||
}),
|
||||
})
|
||||
)
|
||||
: bookingCreateBodySchemaForApi;
|
||||
|
||||
const reqBody = bookingDataSchema.parse(req.body);
|
||||
if ("responses" in reqBody) {
|
||||
const responses = reqBody.responses;
|
||||
const userFieldsResponses = {} as typeof responses;
|
||||
eventType.bookingFields.forEach((field) => {
|
||||
if (field.editable === "user" || field.editable === "user-readonly") {
|
||||
userFieldsResponses[field.name] = responses[field.name];
|
||||
}
|
||||
});
|
||||
return {
|
||||
...reqBody,
|
||||
name: responses.name,
|
||||
email: responses.email,
|
||||
guests: responses.guests ? responses.guests : [],
|
||||
location: responses.location?.optionValue || responses.location?.value || "",
|
||||
smsReminderNumber: responses.smsReminderNumber,
|
||||
notes: responses.notes || "",
|
||||
userFieldsResponses,
|
||||
rescheduleReason: responses.rescheduleReason,
|
||||
};
|
||||
} else {
|
||||
// Check if required custom inputs exist
|
||||
handleCustomInputs(eventType.customInputs as EventTypeCustomInput[], reqBody.customInputs);
|
||||
|
||||
return {
|
||||
...reqBody,
|
||||
name: reqBody.name,
|
||||
email: reqBody.email,
|
||||
guests: reqBody.guests,
|
||||
location: reqBody.location || "",
|
||||
smsReminderNumber: reqBody.smsReminderNumber,
|
||||
notes: reqBody.notes,
|
||||
rescheduleReason: reqBody.rescheduleReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomInputsResponses(
|
||||
reqBody: {
|
||||
responses?: Record<string, any>;
|
||||
customInputs?: z.infer<typeof bookingCreateSchemaLegacyPropsForApi>["customInputs"];
|
||||
},
|
||||
eventTypeCustomInputs: Awaited<ReturnType<typeof getEventTypesFromDB>>["customInputs"]
|
||||
) {
|
||||
const customInputsResponses = {} as NonNullable<CalendarEvent["customInputs"]>;
|
||||
if ("customInputs" in reqBody) {
|
||||
const reqCustomInputsResponses = reqBody.customInputs || [];
|
||||
if (reqCustomInputsResponses?.length > 0) {
|
||||
reqCustomInputsResponses.forEach(({ label, value }) => {
|
||||
customInputsResponses[label] = value;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const responses = reqBody.responses || {};
|
||||
// Backward Compatibility: Map new `responses` to old `customInputs` format so that webhooks can still receive same values.
|
||||
for (const [fieldName, fieldValue] of Object.entries(responses)) {
|
||||
const foundACustomInputForTheResponse = eventTypeCustomInputs.find(
|
||||
(input) => slugify(input.label) === fieldName
|
||||
);
|
||||
if (foundACustomInputForTheResponse) {
|
||||
customInputsResponses[foundACustomInputForTheResponse.label] = fieldValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return customInputsResponses;
|
||||
}
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest & { userId?: number | undefined },
|
||||
{
|
||||
isNotAnApiCall = false,
|
||||
}: {
|
||||
isNotAnApiCall?: boolean;
|
||||
} = {
|
||||
isNotAnApiCall: false,
|
||||
}
|
||||
) {
|
||||
const { userId } = req;
|
||||
|
||||
// handle dynamic user
|
||||
let eventType =
|
||||
!req.body.eventTypeId && !!req.body.eventTypeSlug
|
||||
? getDefaultEvent(req.body.eventTypeSlug)
|
||||
: await getEventTypesFromDB(req.body.eventTypeId);
|
||||
|
||||
eventType = {
|
||||
...eventType,
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
};
|
||||
|
||||
const {
|
||||
recurringCount,
|
||||
allRecurringDates,
|
||||
currentRecurringIndex,
|
||||
noEmail,
|
||||
eventTypeSlug,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
hasHashedBookingLink,
|
||||
language,
|
||||
appsStatus: reqAppsStatus,
|
||||
name: bookerName,
|
||||
email: bookerEmail,
|
||||
guests: reqGuests,
|
||||
location,
|
||||
notes: additionalNotes,
|
||||
smsReminderNumber,
|
||||
rescheduleReason,
|
||||
...reqBody
|
||||
} = extendedBookingCreateBody.parse(req.body);
|
||||
} = getBookingData({
|
||||
req,
|
||||
isNotAnApiCall,
|
||||
eventType,
|
||||
});
|
||||
|
||||
// handle dynamic user
|
||||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
? getGroupName(reqBody.user)
|
||||
: getUsernameList(reqBody.user);
|
||||
const tAttendees = await getTranslation(language ?? "en", "common");
|
||||
const tGuests = await getTranslation("en", "common");
|
||||
log.debug(`Booking eventType ${eventTypeId} started`);
|
||||
|
||||
const eventType =
|
||||
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
|
||||
|
||||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
? getGroupName(reqBody.user)
|
||||
: getUsernameList(reqBody.user);
|
||||
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
|
||||
|
||||
const isTeamEventType =
|
||||
|
@ -341,9 +462,6 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
// Check if required custom inputs exist
|
||||
handleCustomInputs(eventType.customInputs as EventTypeCustomInput[], reqBody.customInputs);
|
||||
|
||||
let timeOutOfBounds = false;
|
||||
try {
|
||||
timeOutOfBounds = isOutOfBounds(reqBody.start, {
|
||||
|
@ -493,14 +611,14 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
|
||||
const invitee = [
|
||||
{
|
||||
email: reqBody.email,
|
||||
name: reqBody.name,
|
||||
email: bookerEmail,
|
||||
name: bookerName,
|
||||
timeZone: reqBody.timeZone,
|
||||
language: { translate: tAttendees, locale: language ?? "en" },
|
||||
},
|
||||
];
|
||||
|
||||
const guests = (reqBody.guests || []).reduce((guestArray, guest) => {
|
||||
const guests = (reqGuests || []).reduce((guestArray, guest) => {
|
||||
// If it's a team event, remove the team member from guests
|
||||
if (isTeamEventType) {
|
||||
if (users.some((user) => user.email === guest)) {
|
||||
|
@ -520,7 +638,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
|
||||
let locationBodyString = reqBody.location;
|
||||
let locationBodyString = location;
|
||||
let defaultLocationUrl = undefined;
|
||||
if (dynamicUserList.length > 1) {
|
||||
users = users.sort((a, b) => {
|
||||
|
@ -535,8 +653,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
}
|
||||
|
||||
const bookingLocation = getLocationValueForDB(locationBodyString, eventType.locations);
|
||||
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
|
||||
|
||||
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
|
||||
const teamMemberPromises =
|
||||
users.length > 1
|
||||
? users.slice(1).map(async function (user) {
|
||||
|
@ -557,16 +674,16 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
const attendeesList = [...invitee, ...guests];
|
||||
|
||||
const eventNameObject = {
|
||||
attendeeName: reqBody.name || "Nameless",
|
||||
//TODO: Can we have an unnamed attendee? If not, I would really like to throw an error here.
|
||||
attendeeName: bookerName || "Nameless",
|
||||
eventType: eventType.title,
|
||||
eventName: eventType.eventName,
|
||||
// TODO: Can we have an unnamed organizer? If not, I would really like to throw an error here.
|
||||
host: organizerUser.name || "Nameless",
|
||||
location: bookingLocation,
|
||||
t: tOrganizer,
|
||||
};
|
||||
|
||||
const additionalNotes = reqBody.notes;
|
||||
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
|
@ -575,6 +692,8 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
}
|
||||
}
|
||||
|
||||
const responses = "responses" in reqBody ? reqBody.responses : null;
|
||||
const userFieldsResponses = "userFieldsResponses" in reqBody ? reqBody.userFieldsResponses : null;
|
||||
let evt: CalendarEvent = {
|
||||
type: eventType.title,
|
||||
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
|
||||
|
@ -590,6 +709,8 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
timeZone: organizerUser.timeZone,
|
||||
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
|
||||
},
|
||||
responses,
|
||||
userFieldsResponses,
|
||||
attendees: attendeesList,
|
||||
location: bookingLocation, // Will be processed by the EventManager later.
|
||||
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
|
||||
|
@ -729,12 +850,6 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
return booking;
|
||||
}
|
||||
|
||||
if (reqBody.customInputs.length > 0) {
|
||||
reqBody.customInputs.forEach(({ label, value }) => {
|
||||
customInputs[label] = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (isTeamEventType) {
|
||||
evt.team = {
|
||||
members: teamMembers,
|
||||
|
@ -782,6 +897,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
if (rescheduleUid) {
|
||||
|
@ -813,6 +929,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
|
||||
const newBookingData: Prisma.BookingCreateInput = {
|
||||
uid,
|
||||
responses: responses === null ? Prisma.JsonNull : responses,
|
||||
title: evt.title,
|
||||
startTime: dayjs.utc(evt.startTime).toDate(),
|
||||
endTime: dayjs.utc(evt.endTime).toDate(),
|
||||
|
@ -821,7 +938,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
|
||||
location: evt.location,
|
||||
eventType: eventTypeRel,
|
||||
smsReminderNumber: reqBody.smsReminderNumber,
|
||||
smsReminderNumber,
|
||||
metadata: reqBody.metadata,
|
||||
attendees: {
|
||||
createMany: {
|
||||
|
@ -981,7 +1098,6 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
}
|
||||
|
||||
let videoCallUrl;
|
||||
|
||||
if (originalRescheduledBooking?.uid) {
|
||||
try {
|
||||
// cancel workflow reminders from previous rescheduled booking
|
||||
|
@ -1001,7 +1117,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
booking?.id,
|
||||
reqBody.rescheduleReason
|
||||
rescheduleReason
|
||||
);
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
|
@ -1037,7 +1153,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
...evt,
|
||||
additionalInformation: metadata,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: "$RCH$" + reqBody.rescheduleReason, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
cancellationReason: "$RCH$" + rescheduleReason, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1295,7 +1411,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
try {
|
||||
await scheduleWorkflowReminders(
|
||||
eventType.workflows,
|
||||
reqBody.smsReminderNumber as string | null,
|
||||
smsReminderNumber || null,
|
||||
{ ...evt, ...{ metadata } },
|
||||
evt.requiresConfirmation || false,
|
||||
rescheduleUid ? true : false,
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import jackson from "@boxyhq/saml-jackson";
|
||||
import type {
|
||||
IConnectionAPIController,
|
||||
IOAuthController,
|
||||
JacksonOption,
|
||||
ISPSAMLConfig,
|
||||
} from "@boxyhq/saml-jackson";
|
||||
import jackson from "@boxyhq/saml-jackson";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import {WEBAPP_URL} from "@calcom/lib/constants";
|
||||
|
||||
import { samlDatabaseUrl, samlAudience, samlPath, oidcPath } from "./saml";
|
||||
import {samlDatabaseUrl, samlAudience, samlPath, oidcPath} from "./saml";
|
||||
|
||||
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
|
||||
const opts: JacksonOption = {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
|
|
|
@ -166,13 +166,19 @@ export const AddActionDialog = (props: IAddActionDialog) => {
|
|||
<div className="mt-5 space-y-1">
|
||||
<Label htmlFor="sendTo">{t("phone_number")}</Label>
|
||||
<div className="mt-1 mb-5">
|
||||
<PhoneInput<AddActionFormValues>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="sendTo"
|
||||
className="rounded-md"
|
||||
placeholder={t("enter_phone_number")}
|
||||
id="sendTo"
|
||||
required
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<PhoneInput
|
||||
className="rounded-md"
|
||||
placeholder={t("enter_phone_number")}
|
||||
id="sendTo"
|
||||
required
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.sendTo && (
|
||||
<p className="mt-1 text-sm text-red-500">{form.formState.errors.sendTo.message}</p>
|
||||
|
|
|
@ -36,6 +36,7 @@ const WorkflowListItem = (props: ItemProps) => {
|
|||
);
|
||||
|
||||
const isActive = activeEventTypeIds.includes(eventType.id);
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
|
||||
onSuccess: async () => {
|
||||
|
@ -52,6 +53,7 @@ const WorkflowListItem = (props: ItemProps) => {
|
|||
setActiveEventTypeIds(newActiveEventTypeIds);
|
||||
offOn = "on";
|
||||
}
|
||||
await utils.viewer.eventTypes.get.invalidate({ id: eventType.id });
|
||||
showToast(
|
||||
t("workflow_turned_on_successfully", {
|
||||
workflowName: workflow.name,
|
||||
|
|
|
@ -397,19 +397,26 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
|||
<div className="mt-2 rounded-md bg-gray-50 p-4 pt-0">
|
||||
<Label className="pt-4">{t("custom_phone_number")}</Label>
|
||||
<div className="block sm:flex">
|
||||
<PhoneInput<FormValues>
|
||||
control={form.control}
|
||||
<Controller
|
||||
name={`steps.${step.stepNumber - 1}.sendTo`}
|
||||
placeholder={t("phone_number")}
|
||||
id={`steps.${step.stepNumber - 1}.sendTo`}
|
||||
className="min-w-fit sm:rounded-tl-md sm:rounded-bl-md sm:border-r-transparent"
|
||||
required
|
||||
onChange={() => {
|
||||
const isAlreadyVerified = !!verifiedNumbers
|
||||
?.concat([])
|
||||
.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`));
|
||||
setNumberVerified(isAlreadyVerified);
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<PhoneInput
|
||||
placeholder={t("phone_number")}
|
||||
id={`steps.${step.stepNumber - 1}.sendTo`}
|
||||
className="min-w-fit sm:rounded-tl-md sm:rounded-bl-md sm:border-r-transparent"
|
||||
required
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
const isAlreadyVerified = !!verifiedNumbers
|
||||
?.concat([])
|
||||
.find(
|
||||
(number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)
|
||||
);
|
||||
setNumberVerified(isAlreadyVerified);
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge, Button, ButtonGroup } from "@calcom/ui";
|
||||
import { FiTrash } from "@calcom/ui/components/icon";
|
||||
|
||||
type Props = {
|
||||
required?: boolean;
|
||||
question?: string;
|
||||
type?: string;
|
||||
editOnClick: () => void;
|
||||
deleteOnClick: () => void;
|
||||
};
|
||||
|
||||
function CustomInputItem({ required, deleteOnClick, editOnClick, type, question }: Props) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<li className="flex rounded-b-md border-t border-gray-200 bg-white px-6 py-4 first:rounded-t-md first:border-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center">
|
||||
<span className="pr-2 text-sm font-semibold leading-none text-black">{question}</span>
|
||||
<Badge variant="default" color="gray" withDot={false}>
|
||||
{required ? t("required") : t("optional")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-normal text-gray-600">{type}</p>
|
||||
</div>
|
||||
<ButtonGroup containerProps={{ className: "ml-auto" }}>
|
||||
<Button color="secondary" onClick={editOnClick}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
StartIcon={FiTrash}
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
onClick={deleteOnClick}
|
||||
className="h-[36px] border border-gray-200"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomInputItem;
|
|
@ -1,7 +1,6 @@
|
|||
import dynamic from "next/dynamic";
|
||||
|
||||
export { default as CheckedTeamSelect } from "./CheckedTeamSelect";
|
||||
export { default as CustomInputItem } from "./CustomInputItem";
|
||||
export { default as CreateEventTypeDialog } from "./CreateEventTypeDialog";
|
||||
export { default as EventTypeDescription } from "./EventTypeDescription";
|
||||
export const EventTypeDescriptionLazy = dynamic(() => import("./EventTypeDescription"));
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
type Field = z.infer<typeof eventTypeBookingFields>[number];
|
||||
|
||||
async function getEventType(eventTypeId: EventType["id"]) {
|
||||
const rawEventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
include: {
|
||||
customInputs: true,
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!rawEventType) {
|
||||
throw new Error(`EventType:${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const eventType = {
|
||||
...rawEventType,
|
||||
bookingFields: getBookingFieldsWithSystemFields(rawEventType),
|
||||
};
|
||||
return eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fieldToAdd Field to add
|
||||
* @param source Source of the field to be shown in UI
|
||||
* @param eventTypeId
|
||||
*/
|
||||
export async function upsertBookingField(
|
||||
fieldToAdd: Omit<Field, "required">,
|
||||
source: NonNullable<Field["sources"]>[number],
|
||||
eventTypeId: EventType["id"]
|
||||
) {
|
||||
const eventType = await getEventType(eventTypeId);
|
||||
let fieldFound = false;
|
||||
const newFields = eventType.bookingFields.map((f) => {
|
||||
if (f.name === fieldToAdd.name) {
|
||||
fieldFound = true;
|
||||
|
||||
const currentSources = f.sources ? f.sources : ([] as NonNullable<typeof f.sources>[]);
|
||||
let sourceFound = false;
|
||||
let newSources = currentSources.map((s) => {
|
||||
if (s.id !== source.id) {
|
||||
// If the source is not found, nothing to update
|
||||
return s;
|
||||
}
|
||||
sourceFound = true;
|
||||
|
||||
return {
|
||||
...s,
|
||||
...source,
|
||||
};
|
||||
});
|
||||
|
||||
if (!sourceFound) {
|
||||
newSources = [...newSources, source];
|
||||
}
|
||||
const newField = {
|
||||
...f,
|
||||
// If any source requires the field, mark the field required
|
||||
required: newSources.some((s) => s.fieldRequired),
|
||||
sources: newSources,
|
||||
};
|
||||
return newField;
|
||||
}
|
||||
return f;
|
||||
});
|
||||
if (!fieldFound) {
|
||||
newFields.push({
|
||||
...fieldToAdd,
|
||||
required: source.fieldRequired,
|
||||
sources: [source],
|
||||
});
|
||||
}
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
data: {
|
||||
bookingFields: newFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeBookingField(
|
||||
fieldToRemove: Pick<Field, "name">,
|
||||
source: Pick<NonNullable<Field["sources"]>[number], "id" | "type">,
|
||||
eventTypeId: EventType["id"]
|
||||
) {
|
||||
const eventType = await getEventType(eventTypeId);
|
||||
|
||||
const newFields = eventType.bookingFields
|
||||
.map((f) => {
|
||||
if (f.name === fieldToRemove.name) {
|
||||
const currentSources = f.sources ? f.sources : ([] as NonNullable<typeof f.sources>[]);
|
||||
if (!currentSources.find((s) => s.id === source.id)) {
|
||||
// No need to remove the source - It doesn't exist already
|
||||
return f;
|
||||
}
|
||||
const newSources = currentSources.filter((s) => s.id !== source.id);
|
||||
const newField = {
|
||||
...f,
|
||||
required: newSources.some((s) => s.fieldRequired),
|
||||
sources: newSources,
|
||||
};
|
||||
if (newField.sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return newField;
|
||||
}
|
||||
return f;
|
||||
})
|
||||
.filter((f): f is Field => !!f);
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
data: {
|
||||
bookingFields: newFields,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,384 @@
|
|||
import { useEffect } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type {
|
||||
TextLikeComponentProps,
|
||||
SelectLikeComponentProps,
|
||||
} from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets";
|
||||
import Widgets from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingFieldType } from "@calcom/prisma/zod-utils";
|
||||
import { PhoneInput, AddressInput, Button, Label, Group, RadioField, EmailField, Tooltip } from "@calcom/ui";
|
||||
import { FiUserPlus, FiX } from "@calcom/ui/components/icon";
|
||||
|
||||
import { ComponentForField } from "./FormBuilder";
|
||||
import type { fieldsSchema } from "./FormBuilderFieldsSchema";
|
||||
|
||||
type Component =
|
||||
| {
|
||||
propsType: "text";
|
||||
factory: <TProps extends TextLikeComponentProps>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "textList";
|
||||
factory: <TProps extends TextLikeComponentProps<string[]>>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "select";
|
||||
factory: <TProps extends SelectLikeComponentProps>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "boolean";
|
||||
factory: <TProps extends TextLikeComponentProps<boolean>>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "multiselect";
|
||||
factory: <TProps extends SelectLikeComponentProps<string[]>>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
// Objective type question with option having a possible input
|
||||
propsType: "objectiveWithInput";
|
||||
factory: <
|
||||
TProps extends SelectLikeComponentProps<{
|
||||
value: string;
|
||||
optionValue: string;
|
||||
}> & {
|
||||
optionsInputs: NonNullable<z.infer<typeof fieldsSchema>[number]["optionsInputs"]>;
|
||||
value: { value: string; optionValue: string };
|
||||
} & {
|
||||
name?: string;
|
||||
}
|
||||
>(
|
||||
props: TProps
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
// TODO: Share FormBuilder components across react-query-awesome-builder(for Routing Forms) widgets.
|
||||
// There are certain differences b/w two. Routing Forms expect label to be provided by the widget itself and FormBuilder adds label itself and expect no label to be added by component.
|
||||
// Routing Form approach is better as it provides more flexibility to show the label in complex components. But that can't be done right now because labels are missing consistent asterisk required support across different components
|
||||
export const Components: Record<BookingFieldType, Component> = {
|
||||
text: {
|
||||
propsType: "text",
|
||||
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
|
||||
},
|
||||
textarea: {
|
||||
propsType: "text",
|
||||
// TODO: Make rows configurable in the form builder
|
||||
factory: (props) => <Widgets.TextAreaWidget rows={3} {...props} />,
|
||||
},
|
||||
number: {
|
||||
propsType: "text",
|
||||
factory: (props) => <Widgets.NumberWidget noLabel={true} {...props} />,
|
||||
},
|
||||
name: {
|
||||
propsType: "text",
|
||||
// Keep special "name" type field and later build split(FirstName and LastName) variant of it.
|
||||
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
|
||||
},
|
||||
phone: {
|
||||
propsType: "text",
|
||||
factory: ({ setValue, readOnly, ...props }) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PhoneInput
|
||||
disabled={readOnly}
|
||||
onChange={(val: string) => {
|
||||
setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
email: {
|
||||
propsType: "text",
|
||||
factory: (props) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
return <Widgets.TextWidget type="email" noLabel={true} {...props} />;
|
||||
},
|
||||
},
|
||||
address: {
|
||||
propsType: "text",
|
||||
factory: (props) => {
|
||||
return (
|
||||
<AddressInput
|
||||
onChange={(val) => {
|
||||
props.setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
multiemail: {
|
||||
propsType: "textList",
|
||||
//TODO: Make it a ui component
|
||||
factory: function MultiEmail({ value, readOnly, label, setValue, ...props }) {
|
||||
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>
|
||||
<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
|
||||
disabled={readOnly}
|
||||
value={value[index]}
|
||||
onChange={(e) => {
|
||||
value[index] = e.target.value;
|
||||
setValue(value);
|
||||
}}
|
||||
className={classNames(inputClassName, "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 "
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
label={<></>}
|
||||
required
|
||||
addOnSuffix={
|
||||
!readOnly ? (
|
||||
<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>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
StartIcon={FiUserPlus}
|
||||
className="my-2.5"
|
||||
onClick={() => {
|
||||
value.push("");
|
||||
setValue(value);
|
||||
}}>
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{!value.length && !readOnly && (
|
||||
<Button
|
||||
color="minimal"
|
||||
variant="button"
|
||||
StartIcon={FiUserPlus}
|
||||
onClick={() => {
|
||||
value.push("");
|
||||
setValue(value);
|
||||
}}
|
||||
className="mr-auto">
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
multiselect: {
|
||||
propsType: "multiselect",
|
||||
factory: (props) => {
|
||||
const newProps = {
|
||||
...props,
|
||||
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
|
||||
};
|
||||
return <Widgets.MultiSelectWidget {...newProps} />;
|
||||
},
|
||||
},
|
||||
select: {
|
||||
propsType: "select",
|
||||
factory: (props) => {
|
||||
const newProps = {
|
||||
...props,
|
||||
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
|
||||
};
|
||||
return <Widgets.SelectWidget {...newProps} />;
|
||||
},
|
||||
},
|
||||
checkbox: {
|
||||
propsType: "multiselect",
|
||||
factory: ({ options, readOnly, setValue, value }) => {
|
||||
value = value || [];
|
||||
return (
|
||||
<div>
|
||||
{options.map((option, i) => {
|
||||
return (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const newValue = value.filter((v) => v !== option.value);
|
||||
if (e.target.checked) {
|
||||
newValue.push(option.value);
|
||||
}
|
||||
setValue(newValue);
|
||||
}}
|
||||
className="dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 rounded border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
value={option.value}
|
||||
checked={value.includes(option.value)}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||
{option.label ?? ""}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
radio: {
|
||||
propsType: "select",
|
||||
factory: ({ setValue, value, options }) => {
|
||||
return (
|
||||
<Group
|
||||
value={value}
|
||||
onValueChange={(e) => {
|
||||
setValue(e);
|
||||
}}>
|
||||
<>
|
||||
{options.map((option, i) => (
|
||||
<RadioField
|
||||
label={option.label}
|
||||
key={`option.${i}.radio`}
|
||||
value={option.label}
|
||||
id={`option.${i}.radio`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
radioInput: {
|
||||
propsType: "objectiveWithInput",
|
||||
factory: function RadioInputWithLabel({ name, options, optionsInputs, value, setValue, readOnly }) {
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setValue({
|
||||
value: options[0]?.value,
|
||||
optionValue: "",
|
||||
});
|
||||
}
|
||||
}, [options, setValue, value]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{options.length > 1 ? (
|
||||
options.map((option, i) => {
|
||||
return (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
className="dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
setValue({
|
||||
value: e.target.value,
|
||||
optionValue: "",
|
||||
});
|
||||
}}
|
||||
checked={value?.value === option.value}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||
{option.label ?? ""}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Show option itself as label because there is just one option
|
||||
// TODO: Support asterisk for required fields
|
||||
<Label>{options[0].label}</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const optionField = optionsInputs[value?.value];
|
||||
if (!optionField) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
readOnly={!!readOnly}
|
||||
field={{
|
||||
...optionField,
|
||||
name: "optionField",
|
||||
}}
|
||||
value={value?.optionValue}
|
||||
setValue={(val: string) => {
|
||||
setValue({
|
||||
value: value?.value,
|
||||
optionValue: val,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
boolean: {
|
||||
propsType: "boolean",
|
||||
factory: ({ readOnly, label, value, setValue }) => {
|
||||
return (
|
||||
<div className="flex">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setValue(true);
|
||||
} else {
|
||||
setValue(false);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
checked={value}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Label className="-mt-px block text-sm font-medium text-gray-700 dark:text-white">{label}</Label>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
// Should use `statisfies` to check if the `type` is from supported types. But satisfies doesn't work with Next.js config
|
|
@ -0,0 +1,733 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Label,
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
Form,
|
||||
BooleanToggleGroupField,
|
||||
SelectField,
|
||||
InputField,
|
||||
Input,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Switch } from "@calcom/ui";
|
||||
import { FiArrowDown, FiArrowUp, FiX, FiPlus, FiTrash2, FiInfo } from "@calcom/ui/components/icon";
|
||||
|
||||
import { Components } from "./Components";
|
||||
import type { fieldsSchema } from "./FormBuilderFieldsSchema";
|
||||
|
||||
type RhfForm = {
|
||||
fields: z.infer<typeof fieldsSchema>;
|
||||
};
|
||||
|
||||
type RhfFormFields = RhfForm["fields"];
|
||||
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({
|
||||
title,
|
||||
description,
|
||||
addFieldLabel,
|
||||
formProp,
|
||||
}: {
|
||||
formProp: string;
|
||||
title: string;
|
||||
description: string;
|
||||
addFieldLabel: string;
|
||||
}) {
|
||||
const FieldTypesMap: Record<
|
||||
string,
|
||||
{
|
||||
value: RhfForm["fields"][number]["type"];
|
||||
label: string;
|
||||
needsOptions?: boolean;
|
||||
systemOnly?: boolean;
|
||||
isTextType?: boolean;
|
||||
}
|
||||
> = {
|
||||
name: {
|
||||
label: "Name",
|
||||
value: "name",
|
||||
isTextType: true,
|
||||
},
|
||||
email: {
|
||||
label: "Email",
|
||||
value: "email",
|
||||
isTextType: true,
|
||||
},
|
||||
phone: {
|
||||
label: "Phone",
|
||||
value: "phone",
|
||||
isTextType: true,
|
||||
},
|
||||
text: {
|
||||
label: "Short Text",
|
||||
value: "text",
|
||||
isTextType: true,
|
||||
},
|
||||
number: {
|
||||
label: "Number",
|
||||
value: "number",
|
||||
isTextType: true,
|
||||
},
|
||||
textarea: {
|
||||
label: "Long Text",
|
||||
value: "textarea",
|
||||
isTextType: true,
|
||||
},
|
||||
select: {
|
||||
label: "Select",
|
||||
value: "select",
|
||||
needsOptions: true,
|
||||
isTextType: true,
|
||||
},
|
||||
multiselect: {
|
||||
label: "MultiSelect",
|
||||
value: "multiselect",
|
||||
needsOptions: true,
|
||||
isTextType: false,
|
||||
},
|
||||
multiemail: {
|
||||
label: "Multiple Emails",
|
||||
value: "multiemail",
|
||||
isTextType: true,
|
||||
},
|
||||
radioInput: {
|
||||
label: "Radio Input",
|
||||
value: "radioInput",
|
||||
isTextType: false,
|
||||
systemOnly: true,
|
||||
},
|
||||
checkbox: {
|
||||
label: "Checkbox Group",
|
||||
value: "checkbox",
|
||||
needsOptions: true,
|
||||
isTextType: false,
|
||||
},
|
||||
radio: {
|
||||
label: "Radio Group",
|
||||
value: "radio",
|
||||
needsOptions: true,
|
||||
isTextType: false,
|
||||
},
|
||||
boolean: {
|
||||
label: "Checkbox",
|
||||
value: "boolean",
|
||||
isTextType: false,
|
||||
},
|
||||
};
|
||||
const FieldTypes = Object.values(FieldTypesMap);
|
||||
|
||||
// I would have liked to give Form Builder it's own Form but nested Forms aren't something that browsers support.
|
||||
// So, this would reuse the same Form as the parent form.
|
||||
const fieldsForm = useFormContext<RhfForm>();
|
||||
|
||||
const { t } = useLocale();
|
||||
const fieldForm = useForm<RhfFormField>();
|
||||
const { fields, swap, remove, update, append } = useFieldArray({
|
||||
control: fieldsForm.control,
|
||||
// HACK: It allows any property name to be used for instead of `fields` property name
|
||||
name: formProp as unknown as "fields",
|
||||
});
|
||||
|
||||
function OptionsField({
|
||||
label = "Options",
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
readOnly = false,
|
||||
}: {
|
||||
label?: string;
|
||||
value: { label: string; value: string }[];
|
||||
onChange: (value: { label: string; value: string }[]) => void;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||
if (!value) {
|
||||
onChange([
|
||||
{
|
||||
label: "Option 1",
|
||||
value: "Option 1",
|
||||
},
|
||||
{
|
||||
label: "Option 2",
|
||||
value: "Option 2",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label>{label}</Label>
|
||||
<div className="rounded-md bg-gray-50 p-4">
|
||||
<ul ref={animationRef}>
|
||||
{value?.map((option, index) => (
|
||||
<li key={index}>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
required
|
||||
value={option.label}
|
||||
onChange={(e) => {
|
||||
// Right now we use label of the option as the value of the option. It allows us to not separately lookup the optionId to know the optionValue
|
||||
// It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now.
|
||||
value.splice(index, 1, {
|
||||
label: e.target.value,
|
||||
value: e.target.value.toLowerCase().trim(),
|
||||
});
|
||||
onChange(value);
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
placeholder={`Enter Option ${index + 1}`}
|
||||
/>
|
||||
{value.length > 2 && !readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
className="mb-2 -ml-8 hover:!bg-transparent focus:!bg-transparent focus:!outline-none focus:!ring-0"
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon={FiX}
|
||||
onClick={() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const newOptions = [...value];
|
||||
newOptions.splice(index, 1);
|
||||
onChange(newOptions);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
value.push({ label: "", value: "" });
|
||||
onChange(value);
|
||||
}}
|
||||
StartIcon={FiPlus}>
|
||||
Add an Option
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [fieldDialog, setFieldDialog] = useState({
|
||||
isOpen: false,
|
||||
fieldIndex: -1,
|
||||
});
|
||||
|
||||
const addField = () => {
|
||||
fieldForm.reset({});
|
||||
setFieldDialog({
|
||||
isOpen: true,
|
||||
fieldIndex: -1,
|
||||
});
|
||||
};
|
||||
|
||||
const editField = (index: number, data: RhfFormField) => {
|
||||
fieldForm.reset(data);
|
||||
setFieldDialog({
|
||||
isOpen: true,
|
||||
fieldIndex: index,
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const fieldType = FieldTypesMap[fieldForm.watch("type")];
|
||||
const isFieldEditMode = fieldDialog.fieldIndex !== -1;
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 ltr:mr-1 rtl:ml-1">{title}</div>
|
||||
<p className="max-w-[280px] break-words py-1 text-sm text-gray-500 sm:max-w-[500px]">{description}</p>
|
||||
<ul className="mt-2 rounded-md border">
|
||||
{fields.map((field, index) => {
|
||||
const fieldType = FieldTypesMap[field.type];
|
||||
|
||||
// Hidden fields can't be required
|
||||
const isRequired = field.required && !field.hidden;
|
||||
|
||||
if (!fieldType) {
|
||||
throw new Error(`Invalid field type - ${field.type}`);
|
||||
}
|
||||
const sources = field.sources || [];
|
||||
const groupedBySourceLabel = sources.reduce((groupBy, source) => {
|
||||
const item = groupBy[source.label] || [];
|
||||
if (source.type === "user" || source.type === "default") {
|
||||
return groupBy;
|
||||
}
|
||||
item.push(source);
|
||||
groupBy[source.label] = item;
|
||||
return groupBy;
|
||||
}, {} as Record<string, NonNullable<(typeof field)["sources"]>>);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className="group relative flex items-center justify-between border-b p-4 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
className="invisible absolute -left-[12px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
|
||||
onClick={() => swap(index, index - 1)}>
|
||||
<FiArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="invisible absolute -left-[12px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
|
||||
onClick={() => swap(index, index + 1)}>
|
||||
<FiArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center">
|
||||
<div className="text-sm font-semibold text-gray-700 ltr:mr-1 rtl:ml-1">
|
||||
{field.label || t(field.defaultLabel || "")}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="gray">{isRequired ? "Required" : "Optional"}</Badge>
|
||||
{field.hidden ? <Badge variant="gray">Hidden</Badge> : null}
|
||||
{Object.entries(groupedBySourceLabel).map(([sourceLabel, sources], key) => (
|
||||
// We don't know how to pluralize `sourceLabel` because it can be anything
|
||||
<Badge key={key} variant="blue">
|
||||
{sources.length} {sources.length === 1 ? sourceLabel : `${sourceLabel}s`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-w-[280px] break-words py-1 text-sm text-gray-500 sm:max-w-[500px]">
|
||||
{fieldType.label}
|
||||
</p>
|
||||
</div>
|
||||
{field.editable !== "user-readonly" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
disabled={field.editable === "system"}
|
||||
tooltip={field.editable === "system" ? t("form_builder_system_field_cant_toggle") : ""}
|
||||
checked={!field.hidden}
|
||||
onCheckedChange={(checked) => {
|
||||
update(index, { ...field, hidden: !checked });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
editField(index, field);
|
||||
}}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
color="minimal"
|
||||
tooltip={
|
||||
field.editable === "system" || field.editable === "system-but-optional"
|
||||
? t("form_builder_system_field_cant_delete")
|
||||
: ""
|
||||
}
|
||||
disabled={field.editable === "system" || field.editable === "system-but-optional"}
|
||||
variant="icon"
|
||||
onClick={() => {
|
||||
removeField(index);
|
||||
}}
|
||||
StartIcon={FiTrash2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Button color="minimal" onClick={addField} className="mt-4" StartIcon={FiPlus}>
|
||||
{addFieldLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={fieldDialog.isOpen}
|
||||
onOpenChange={(isOpen) =>
|
||||
setFieldDialog({
|
||||
isOpen,
|
||||
fieldIndex: -1,
|
||||
})
|
||||
}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("add_a_booking_question")} subtitle={t("form_builder_field_add_subtitle")} />
|
||||
<div>
|
||||
<Form
|
||||
form={fieldForm}
|
||||
handleSubmit={(data) => {
|
||||
const isNewField = fieldDialog.fieldIndex == -1;
|
||||
if (isNewField && fields.some((f) => f.name === data.name)) {
|
||||
showToast(t("form_builder_field_already_exists"), "error");
|
||||
return;
|
||||
}
|
||||
if (fieldDialog.fieldIndex !== -1) {
|
||||
update(fieldDialog.fieldIndex, data);
|
||||
} else {
|
||||
const field: RhfFormField = {
|
||||
...data,
|
||||
sources: [
|
||||
{
|
||||
label: "User",
|
||||
type: "user",
|
||||
id: "user",
|
||||
fieldRequired: data.required,
|
||||
},
|
||||
],
|
||||
};
|
||||
field.editable = field.editable || "user";
|
||||
append(field);
|
||||
}
|
||||
setFieldDialog({
|
||||
isOpen: false,
|
||||
fieldIndex: -1,
|
||||
});
|
||||
}}>
|
||||
<SelectField
|
||||
required
|
||||
isDisabled={
|
||||
fieldForm.getValues("editable") === "system" ||
|
||||
fieldForm.getValues("editable") === "system-but-optional"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
fieldForm.setValue("type", value);
|
||||
}}
|
||||
value={FieldTypesMap[fieldForm.getValues("type")]}
|
||||
options={FieldTypes.filter((f) => !f.systemOnly)}
|
||||
label="Input Type"
|
||||
/>
|
||||
<InputField
|
||||
required
|
||||
{...fieldForm.register("name")}
|
||||
containerClassName="mt-6"
|
||||
disabled={
|
||||
fieldForm.getValues("editable") === "system" ||
|
||||
fieldForm.getValues("editable") === "system-but-optional"
|
||||
}
|
||||
label="Name"
|
||||
/>
|
||||
<InputField
|
||||
{...fieldForm.register("label")}
|
||||
// System fields have a defaultLabel, so there a label is not required
|
||||
required={!["system", "system-but-optional"].includes(fieldForm.getValues("editable") || "")}
|
||||
placeholder={t(fieldForm.getValues("defaultLabel") || "")}
|
||||
containerClassName="mt-6"
|
||||
label="Label"
|
||||
/>
|
||||
{fieldType?.isTextType ? (
|
||||
<InputField
|
||||
{...fieldForm.register("placeholder")}
|
||||
containerClassName="mt-6"
|
||||
label="Placeholder"
|
||||
placeholder={t(fieldForm.getValues("defaultPlaceholder") || "")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{fieldType?.needsOptions ? (
|
||||
<Controller
|
||||
name="options"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <OptionsField onChange={onChange} value={value} className="mt-6" />;
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Controller
|
||||
name="required"
|
||||
control={fieldForm.control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<BooleanToggleGroupField
|
||||
disabled={fieldForm.getValues("editable") === "system"}
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label="Required"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose color="secondary">Cancel</DialogClose>
|
||||
<Button type="submit">{isFieldEditMode ? t("save") : t("add")}</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Add consistent `label` support to all the components and then remove the usage of WithLabel.
|
||||
// Label should be handled by each Component itself.
|
||||
const WithLabel = ({
|
||||
field,
|
||||
children,
|
||||
readOnly,
|
||||
}: {
|
||||
field: Partial<RhfFormField>;
|
||||
readOnly: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{/* multiemail doesnt show label initially. It is shown on clicking CTA */}
|
||||
{/* boolean type doesn't have a label overall, the radio has it's own label */}
|
||||
{/* Component itself managing it's label should remove these checks */}
|
||||
{field.type !== "boolean" && field.type !== "multiemail" && field.label && (
|
||||
<div className="mb-2 flex items-center">
|
||||
<Label className="!mb-0 flex items-center">{field.label}</Label>
|
||||
<span className="ml-1 -mb-1 text-sm font-medium leading-none dark:text-white">
|
||||
{!readOnly && field.required ? "*" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ValueProps =
|
||||
| {
|
||||
value: string[];
|
||||
setValue: (value: string[]) => void;
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}
|
||||
| {
|
||||
value: {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
};
|
||||
setValue: (value: { value: string; optionValue: string }) => void;
|
||||
}
|
||||
| {
|
||||
value: boolean;
|
||||
setValue: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const ComponentForField = ({
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
readOnly,
|
||||
}: {
|
||||
field: Omit<RhfFormField, "editable" | "label"> & {
|
||||
// Label is optional because radioInput doesn't have a label
|
||||
label?: string;
|
||||
};
|
||||
readOnly: boolean;
|
||||
} & ValueProps) => {
|
||||
const fieldType = field.type;
|
||||
const componentConfig = Components[fieldType];
|
||||
|
||||
const isValueOfPropsType = (val: unknown, propsType: typeof componentConfig.propsType) => {
|
||||
const propsTypeConditionMap = {
|
||||
boolean: typeof val === "boolean",
|
||||
multiselect: val instanceof Array && val.every((v) => typeof v === "string"),
|
||||
objectiveWithInput: typeof val === "object" && val !== null ? "value" in val : false,
|
||||
select: typeof val === "string",
|
||||
text: typeof val === "string",
|
||||
textList: val instanceof Array && val.every((v) => typeof v === "string"),
|
||||
} as const;
|
||||
if (!propsTypeConditionMap[propsType]) throw new Error(`Unknown propsType ${propsType}`);
|
||||
return propsTypeConditionMap[propsType];
|
||||
};
|
||||
|
||||
// If possible would have wanted `isValueOfPropsType` to narrow the type of `value` and `setValue` accordingly, but can't seem to do it.
|
||||
// So, code following this uses type assertion to tell TypeScript that everything has been validated
|
||||
if (value !== undefined && !isValueOfPropsType(value, componentConfig.propsType)) {
|
||||
throw new Error(
|
||||
`Value ${value} is not valid for type ${componentConfig.propsType} for field ${field.name}`
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "text") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
name={field.name}
|
||||
value={value as string}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "boolean") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
value={value as boolean}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "textList") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
value={value as string[]}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "select") {
|
||||
if (!field.options) {
|
||||
throw new Error("Field options is not defined");
|
||||
}
|
||||
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
readOnly={readOnly}
|
||||
value={value as string}
|
||||
placeholder={field.placeholder}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
options={field.options.map((o) => ({ ...o, title: o.label }))}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "multiselect") {
|
||||
if (!field.options) {
|
||||
throw new Error("Field options is not defined");
|
||||
}
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
readOnly={readOnly}
|
||||
value={value as string[]}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
options={field.options.map((o) => ({ ...o, title: o.label }))}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "objectiveWithInput") {
|
||||
if (!field.options) {
|
||||
throw new Error("Field options is not defined");
|
||||
}
|
||||
if (!field.optionsInputs) {
|
||||
throw new Error("Field optionsInputs is not defined");
|
||||
}
|
||||
return field.options.length ? (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
readOnly={readOnly}
|
||||
name={field.name}
|
||||
value={value as { value: string; optionValue: string }}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
optionsInputs={field.optionsInputs}
|
||||
options={field.options}
|
||||
/>
|
||||
</WithLabel>
|
||||
) : null;
|
||||
}
|
||||
|
||||
throw new Error(`Field ${field.name} does not have a valid propsType`);
|
||||
};
|
||||
|
||||
export const FormBuilderField = ({
|
||||
field,
|
||||
readOnly,
|
||||
className,
|
||||
}: {
|
||||
field: RhfFormFields[number];
|
||||
readOnly: boolean;
|
||||
className: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, formState } = useFormContext();
|
||||
return (
|
||||
<div
|
||||
data-form-builder-field-name={field.name}
|
||||
className={classNames(className, field.hidden ? "hidden" : "")}>
|
||||
<Controller
|
||||
control={control}
|
||||
// Make it a variable
|
||||
name={`responses.${field.name}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
field={field}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
setValue={(val: unknown) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<ErrorMessage
|
||||
name="responses"
|
||||
errors={formState.errors}
|
||||
render={({ message }) => {
|
||||
const name = message?.replace(/\{([^}]+)\}.*/, "$1");
|
||||
// Use the message targeted for it.
|
||||
if (name !== field.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
message = message.replace(/\{[^}]+\}(.*)/, "$1").trim();
|
||||
if (field.hidden) {
|
||||
console.error(`Error message for hidden field:${field.name} => ${message}`);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-field-name={field.name}
|
||||
className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t(message)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const fieldTypeEnum = z.enum([
|
||||
"name",
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"multiemail",
|
||||
"select",
|
||||
"multiselect",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"radioInput",
|
||||
"boolean",
|
||||
]);
|
||||
|
||||
export const EditableSchema = z.enum([
|
||||
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
|
||||
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
|
||||
"user", // Fully editable
|
||||
"user-readonly", // All fields are readOnly.
|
||||
]);
|
||||
|
||||
const fieldSchema = z.object({
|
||||
name: z.string(),
|
||||
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Supports translation
|
||||
*/
|
||||
defaultLabel: z.string().optional(),
|
||||
defaultPlaceholder: z.string().optional(),
|
||||
|
||||
type: fieldTypeEnum,
|
||||
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
||||
optionsInputs: z
|
||||
.record(
|
||||
z.object({
|
||||
// Support all types as needed
|
||||
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
|
||||
type: z.enum(["address", "phone", "text"]),
|
||||
required: z.boolean().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
|
||||
required: z.boolean().default(false).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
editable: z
|
||||
.enum([
|
||||
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
|
||||
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
|
||||
"user", // Fully editable
|
||||
"user-readonly", // All fields are readOnly.
|
||||
])
|
||||
.default("user")
|
||||
.optional(),
|
||||
sources: z
|
||||
.array(
|
||||
z.object({
|
||||
// Unique ID for the `type`. If type is workflow, it's the workflow ID
|
||||
id: z.string(),
|
||||
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
|
||||
label: z.string(),
|
||||
editUrl: z.string().optional(),
|
||||
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
|
||||
fieldRequired: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const fieldsSchema = z.array(fieldSchema);
|
|
@ -0,0 +1,5 @@
|
|||
// TODO: FormBuilder makes more sense in @calcom/ui but it has an additional thing that other components don't have
|
||||
// It has zod schema associated with it and I currently can't import zod in there.
|
||||
// Move it later there maybe? @sean
|
||||
export { FormBuilder } from "./FormBuilder";
|
||||
export { FormBuilderField } from "./FormBuilder";
|
|
@ -71,23 +71,24 @@ ${calEvent.additionalNotes}
|
|||
`;
|
||||
};
|
||||
|
||||
export const getCustomInputs = (calEvent: CalendarEvent) => {
|
||||
if (!calEvent.customInputs) {
|
||||
export const getUserFieldsResponses = (calEvent: CalendarEvent) => {
|
||||
const responses = calEvent.userFieldsResponses || calEvent.customInputs;
|
||||
if (!responses) {
|
||||
return "";
|
||||
}
|
||||
const customInputsString = Object.keys(calEvent.customInputs)
|
||||
const responsesString = Object.keys(responses)
|
||||
.map((key) => {
|
||||
if (!calEvent.customInputs) return "";
|
||||
if (calEvent.customInputs[key] !== "") {
|
||||
if (!responses) return "";
|
||||
if (responses[key] !== "") {
|
||||
return `
|
||||
${key}:
|
||||
${calEvent.customInputs[key]}
|
||||
${responses[key]}
|
||||
`;
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
|
||||
return customInputsString;
|
||||
return responsesString;
|
||||
};
|
||||
|
||||
export const getAppsStatus = (calEvent: CalendarEvent) => {
|
||||
|
@ -171,7 +172,7 @@ ${calEvent.organizer.language.translate("where")}:
|
|||
${getLocation(calEvent)}
|
||||
${getDescription(calEvent)}
|
||||
${getAdditionalNotes(calEvent)}
|
||||
${getCustomInputs(calEvent)}
|
||||
${getUserFieldsResponses(calEvent)}
|
||||
${getAppsStatus(calEvent)}
|
||||
${
|
||||
// TODO: Only the original attendee can make changes to the event
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
|
|||
import { PeriodType, SchedulingType } from "@prisma/client";
|
||||
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import type { userSelect } from "@calcom/prisma/selects";
|
||||
import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
@ -89,6 +90,14 @@ const commons = {
|
|||
users: [user],
|
||||
hosts: [],
|
||||
metadata: EventTypeMetaDataSchema.parse({}),
|
||||
bookingFields: getBookingFieldsWithSystemFields({
|
||||
bookingFields: [],
|
||||
customInputs: [],
|
||||
// Default value of disableGuests from DB.
|
||||
disableGuests: false,
|
||||
metadata: {},
|
||||
workflows: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const min15Event = {
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
|
|||
import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getEventTypeAppData, getLocationGroupedOptions } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
|
@ -100,6 +101,7 @@ export default async function getEventTypeById({
|
|||
bookingLimits: true,
|
||||
successRedirectUrl: true,
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -182,7 +184,7 @@ export default async function getEventTypeById({
|
|||
if (isTrpcCall) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
} else {
|
||||
throw new Error("Event type noy found");
|
||||
throw new Error("Event type not found");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -265,6 +267,7 @@ export default async function getEventTypeById({
|
|||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
});
|
||||
|
||||
const teamMembers = eventTypeObject.team
|
||||
|
|
|
@ -56,6 +56,7 @@ export const buildBooking = (booking?: Partial<Booking>): Booking => {
|
|||
smsReminderNumber: null,
|
||||
scheduledJobs: [],
|
||||
metadata: null,
|
||||
responses: null,
|
||||
...booking,
|
||||
};
|
||||
};
|
||||
|
@ -96,6 +97,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
|||
slotInterval: null,
|
||||
metadata: null,
|
||||
successRedirectUrl: null,
|
||||
bookingFields: null,
|
||||
...eventType,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "responses" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "bookingFields" JSONB;
|
|
@ -63,6 +63,8 @@ model EventType {
|
|||
destinationCalendar DestinationCalendar?
|
||||
eventName String?
|
||||
customInputs EventTypeCustomInput[]
|
||||
/// @zod.custom(imports.eventTypeBookingFields)
|
||||
bookingFields Json?
|
||||
timeZone String?
|
||||
periodType PeriodType @default(UNLIMITED)
|
||||
periodStartDate DateTime?
|
||||
|
@ -297,6 +299,8 @@ model Booking {
|
|||
title String
|
||||
description String?
|
||||
customInputs Json?
|
||||
/// @zod.custom(imports.bookingResponses)
|
||||
responses Json?
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
attendees Attendee[]
|
||||
|
|
|
@ -59,7 +59,7 @@ async function createUserAndEventType(opts: {
|
|||
);
|
||||
|
||||
for (const eventTypeInput of opts.eventTypes) {
|
||||
const { _bookings: bookingInputs = [], ...eventTypeData } = eventTypeInput;
|
||||
const { _bookings: bookingFields = [], ...eventTypeData } = eventTypeInput;
|
||||
eventTypeData.userId = user.id;
|
||||
eventTypeData.users = { connect: { id: user.id } };
|
||||
|
||||
|
@ -90,7 +90,7 @@ async function createUserAndEventType(opts: {
|
|||
console.log(
|
||||
`\t📆 Event type ${eventTypeData.slug} with id ${id}, length ${eventTypeData.length}min - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
|
||||
);
|
||||
for (const bookingInput of bookingInputs) {
|
||||
for (const bookingInput of bookingFields) {
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
...bookingInput,
|
||||
|
|
|
@ -35,6 +35,7 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
|||
disableGuests: true,
|
||||
userId: true,
|
||||
seatsPerTimeSlot: true,
|
||||
bookingFields: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { UnitTypeLongPlural } from "dayjs";
|
||||
import type { UnitTypeLongPlural } from "dayjs";
|
||||
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
@ -14,6 +14,7 @@ import type {
|
|||
|
||||
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
|
||||
// Let's not import 118kb just to get an enum
|
||||
|
@ -52,6 +53,29 @@ export const EventTypeMetaDataSchema = z
|
|||
})
|
||||
.nullable();
|
||||
|
||||
export const eventTypeBookingFields = formBuilderFieldsSchema;
|
||||
export const BookingFieldType = eventTypeBookingFields.element.shape.type.Enum;
|
||||
export type BookingFieldType = typeof BookingFieldType extends z.Values<infer T> ? T[number] : never;
|
||||
|
||||
// Validation of user added bookingFields' responses happen using `getBookingResponsesSchema` which requires `eventType`.
|
||||
// So it is a dynamic validation and thus entire validation can't exist here
|
||||
export const bookingResponses = z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
location: z
|
||||
.object({
|
||||
optionValue: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
smsReminderNumber: z.string().optional(),
|
||||
rescheduleReason: z.string().optional(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export const eventTypeLocations = z.array(
|
||||
z.object({
|
||||
// TODO: Couldn't find a way to make it a union of types from App Store locations
|
||||
|
@ -120,14 +144,9 @@ export const stringOrNumber = z.union([
|
|||
export const stringToDayjs = z.string().transform((val) => dayjs(val));
|
||||
|
||||
export const bookingCreateBodySchema = z.object({
|
||||
email: z.string(),
|
||||
end: z.string(),
|
||||
eventTypeId: z.number(),
|
||||
eventTypeSlug: z.string().optional(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
location: z.string(),
|
||||
name: z.string(),
|
||||
notes: z.string().optional(),
|
||||
rescheduleUid: z.string().optional(),
|
||||
recurringEventId: z.string().optional(),
|
||||
start: z.string(),
|
||||
|
@ -135,7 +154,6 @@ export const bookingCreateBodySchema = z.object({
|
|||
user: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
language: z.string(),
|
||||
bookingUid: z.string().optional(),
|
||||
customInputs: z.array(z.object({ label: z.string(), value: z.union([z.string(), z.boolean()]) })),
|
||||
metadata: z.record(z.string()),
|
||||
hasHashedBookingLink: z.boolean().optional(),
|
||||
hashedLink: z.string().nullish(),
|
||||
|
@ -158,14 +176,13 @@ export const bookingConfirmPatchBodySchema = z.object({
|
|||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
// `responses` is merged with it during handleNewBooking call because `responses` schema is dynamic and depends on eventType
|
||||
export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
|
||||
z.object({
|
||||
noEmail: z.boolean().optional(),
|
||||
recurringCount: z.number().optional(),
|
||||
allRecurringDates: z.string().array().optional(),
|
||||
currentRecurringIndex: z.number().optional(),
|
||||
rescheduleReason: z.string().optional(),
|
||||
smsReminderNumber: z.string().optional().nullable(),
|
||||
appsStatus: z
|
||||
.array(
|
||||
z.object({
|
||||
|
@ -181,6 +198,23 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
|
|||
})
|
||||
);
|
||||
|
||||
// It has only the legacy props that are part of `responses` now. The API can still hit old props
|
||||
export const bookingCreateSchemaLegacyPropsForApi = z.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
location: z.string(),
|
||||
smsReminderNumber: z.string().optional().nullable(),
|
||||
rescheduleReason: z.string().optional(),
|
||||
customInputs: z.array(z.object({ label: z.string(), value: z.union([z.string(), z.boolean()]) })),
|
||||
});
|
||||
|
||||
// This is the schema that is used for the API. It has all the legacy props that are part of `responses` now.
|
||||
export const bookingCreateBodySchemaForApi = extendedBookingCreateBody.merge(
|
||||
bookingCreateSchemaLegacyPropsForApi
|
||||
);
|
||||
|
||||
export const schemaBookingCancelParams = z.object({
|
||||
id: z.number().optional(),
|
||||
uid: z.string().optional(),
|
||||
|
|
|
@ -8,6 +8,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
|||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import getApps from "@calcom/app-store/utils";
|
||||
import { updateEvent } from "@calcom/core/CalendarManager";
|
||||
import { validateBookingLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
||||
|
@ -416,6 +417,7 @@ export const eventTypesRouter = router({
|
|||
}),
|
||||
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
|
||||
const { schedulingType, teamId, ...rest } = input;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
// Get Users default conferncing app
|
||||
|
||||
|
@ -537,11 +539,15 @@ export const eventTypesRouter = router({
|
|||
userId,
|
||||
// eslint-disable-next-line
|
||||
teamId,
|
||||
bookingFields,
|
||||
...rest
|
||||
} = input;
|
||||
|
||||
ensureUniqueBookingFields(bookingFields);
|
||||
|
||||
const data: Prisma.EventTypeUpdateInput = {
|
||||
...rest,
|
||||
bookingFields,
|
||||
metadata: rest.metadata === null ? Prisma.DbNull : rest.metadata,
|
||||
};
|
||||
data.locations = locations ?? undefined;
|
||||
|
@ -786,6 +792,7 @@ export const eventTypesRouter = router({
|
|||
recurringEvent: recurringEvent || undefined,
|
||||
bookingLimits: bookingLimits ?? undefined,
|
||||
metadata: metadata === null ? Prisma.DbNull : metadata,
|
||||
bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields,
|
||||
};
|
||||
|
||||
const newEventType = await ctx.prisma.eventType.create({ data });
|
||||
|
@ -823,3 +830,19 @@ export const eventTypesRouter = router({
|
|||
}
|
||||
}),
|
||||
});
|
||||
|
||||
function ensureUniqueBookingFields(fields: z.infer<typeof EventTypeUpdateInput>["bookingFields"]) {
|
||||
if (!fields) {
|
||||
return;
|
||||
}
|
||||
fields.reduce((discoveredFields, field) => {
|
||||
if (discoveredFields[field.name]) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Duplicate booking field name: ${field.name}`,
|
||||
});
|
||||
}
|
||||
discoveredFields[field.name] = true;
|
||||
return discoveredFields;
|
||||
}, {} as Record<string, true>);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,11 @@ import {
|
|||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
SMS_REMINDER_NUMBER_FIELD,
|
||||
getSmsReminderNumberField,
|
||||
getSmsReminderNumberSource,
|
||||
} from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
|
||||
// import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
|
@ -32,6 +37,7 @@ import {
|
|||
verifyPhoneNumber,
|
||||
sendVerificationCode,
|
||||
} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
|
||||
import { upsertBookingField, removeBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager";
|
||||
import { IS_SELF_HOSTED, SENDER_ID, CAL_URL } from "@calcom/lib/constants";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
|
@ -547,6 +553,7 @@ export const workflowsRouter = router({
|
|||
scheduled: boolean;
|
||||
}[]
|
||||
>[] = [];
|
||||
|
||||
removedEventTypes.forEach((eventTypeId) => {
|
||||
const reminderToDelete = ctx.prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
|
@ -567,9 +574,17 @@ export const workflowsRouter = router({
|
|||
scheduled: true,
|
||||
},
|
||||
});
|
||||
|
||||
remindersToDeletePromise.push(reminderToDelete);
|
||||
});
|
||||
|
||||
for (const removedEventType of removedEventTypes) {
|
||||
await removeSmsReminderFieldForBooking({
|
||||
workflowId: id,
|
||||
eventTypeId: removedEventType,
|
||||
});
|
||||
}
|
||||
|
||||
const remindersToDelete = await Promise.all(remindersToDeletePromise);
|
||||
|
||||
//cancel workflow reminders for all bookings from event types that got disabled
|
||||
|
@ -703,6 +718,16 @@ export const workflowsRouter = router({
|
|||
});
|
||||
}
|
||||
|
||||
for (const eventTypeId of activeOn) {
|
||||
await upsertSmsReminderFieldForBooking({
|
||||
workflowId: id,
|
||||
isSmsReminderNumberRequired: input.steps.some(
|
||||
(s) => s.action === WorkflowActions.SMS_ATTENDEE && s.numberRequired
|
||||
),
|
||||
eventTypeId,
|
||||
});
|
||||
}
|
||||
|
||||
userWorkflow.steps.map(async (oldStep) => {
|
||||
const newStep = steps.filter((s) => s.id === oldStep.id)[0];
|
||||
const remindersFromStep = await ctx.prisma.workflowReminder.findMany({
|
||||
|
@ -1268,6 +1293,9 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeWorkflow)
|
||||
|
@ -1291,6 +1319,16 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
eventTypeId,
|
||||
},
|
||||
});
|
||||
await removeBookingField(
|
||||
{
|
||||
name: "smsReminderNumber",
|
||||
},
|
||||
{
|
||||
id: "" + workflowId,
|
||||
type: "workflow",
|
||||
},
|
||||
eventTypeId
|
||||
);
|
||||
} else {
|
||||
await ctx.prisma.workflowsOnEventTypes.create({
|
||||
data: {
|
||||
|
@ -1298,6 +1336,16 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => {
|
||||
return step.action === WorkflowActions.SMS_ATTENDEE && step.numberRequired;
|
||||
});
|
||||
|
||||
await upsertSmsReminderFieldForBooking({
|
||||
workflowId,
|
||||
isSmsReminderNumberRequired,
|
||||
eventTypeId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
sendVerificationCode: authedProcedure
|
||||
|
@ -1463,3 +1511,41 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
};
|
||||
}),
|
||||
});
|
||||
|
||||
async function upsertSmsReminderFieldForBooking({
|
||||
workflowId,
|
||||
eventTypeId,
|
||||
isSmsReminderNumberRequired,
|
||||
}: {
|
||||
workflowId: number;
|
||||
isSmsReminderNumberRequired: boolean;
|
||||
eventTypeId: number;
|
||||
}) {
|
||||
await upsertBookingField(
|
||||
getSmsReminderNumberField(),
|
||||
getSmsReminderNumberSource({
|
||||
workflowId,
|
||||
isSmsReminderNumberRequired,
|
||||
}),
|
||||
eventTypeId
|
||||
);
|
||||
}
|
||||
|
||||
async function removeSmsReminderFieldForBooking({
|
||||
workflowId,
|
||||
eventTypeId,
|
||||
}: {
|
||||
workflowId: number;
|
||||
eventTypeId: number;
|
||||
}) {
|
||||
await removeBookingField(
|
||||
{
|
||||
name: SMS_REMINDER_NUMBER_FIELD,
|
||||
},
|
||||
{
|
||||
id: "" + workflowId,
|
||||
type: "workflow",
|
||||
},
|
||||
eventTypeId
|
||||
);
|
||||
}
|
||||
|
|
|
@ -163,6 +163,12 @@ export interface CalendarEvent {
|
|||
appsStatus?: AppsStatus[];
|
||||
seatsShowAttendees?: boolean | null;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
|
||||
// It has responses to all the fields(system + user)
|
||||
responses?: Prisma.JsonObject | null;
|
||||
|
||||
// It just has responses to only the user fields. It allows to easily iterate over to show only user fields
|
||||
userFieldsResponses?: Prisma.JsonObject | null;
|
||||
}
|
||||
|
||||
export interface EntryPoint {
|
||||
|
|
|
@ -180,6 +180,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
|||
placeholder={placeholder}
|
||||
className={className}
|
||||
{...passThrough}
|
||||
readOnly={readOnly}
|
||||
ref={ref}
|
||||
isFullWidth={inputIsFullWidth}
|
||||
/>
|
||||
|
@ -340,6 +341,7 @@ const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLForm
|
|||
form
|
||||
.handleSubmit(handleSubmit)(event)
|
||||
.catch((err) => {
|
||||
// FIXME: Booking Pages don't have toast, so this error is never shown
|
||||
showToast(`${getErrorFromUnknown(err).message}`, "error");
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -4,7 +4,10 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
|
|||
return (
|
||||
<label
|
||||
{...props}
|
||||
className={classNames("mb-2 block text-sm font-medium leading-none text-gray-700", props.className)}>
|
||||
className={classNames(
|
||||
"mb-2 block text-sm font-medium leading-none text-gray-700 dark:text-white",
|
||||
props.className
|
||||
)}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
|
|
|
@ -120,6 +120,7 @@ export const SelectField = function SelectField<
|
|||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>(
|
||||
props: {
|
||||
required?: boolean;
|
||||
name?: string;
|
||||
containerClassName?: string;
|
||||
label?: string;
|
||||
|
|
|
@ -5,6 +5,14 @@ import React from "react";
|
|||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
import { Tooltip } from "../../tooltip";
|
||||
|
||||
const Wrapper = ({ children, tooltip }: { tooltip?: string; children: React.ReactNode }) => {
|
||||
if (!tooltip) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <Tooltip content={tooltip}>{children}</Tooltip>;
|
||||
};
|
||||
const Switch = (
|
||||
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||
label?: string;
|
||||
|
@ -12,43 +20,46 @@ const Switch = (
|
|||
className?: string;
|
||||
};
|
||||
fitToHeight?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
) => {
|
||||
const { label, fitToHeight, ...primitiveProps } = props;
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className={classNames("flex h-auto w-auto flex-row items-center", fitToHeight && "h-fit")}>
|
||||
<PrimitiveSwitch.Root
|
||||
className={classNames(
|
||||
props.checked ? "bg-gray-900" : "bg-gray-200",
|
||||
primitiveProps.disabled ? "cursor-not-allowed" : "hover:bg-gray-300",
|
||||
"focus:ring-brand-800 h-5 w-[34px] rounded-full shadow-none",
|
||||
props.className
|
||||
)}
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
// Since we dont support global dark mode - we have to style dark mode components specifically on the instance for now
|
||||
// TODO: Remove once we support global dark mode
|
||||
<Wrapper tooltip={props.tooltip}>
|
||||
<div className={classNames("flex h-auto w-auto flex-row items-center", fitToHeight && "h-fit")}>
|
||||
<PrimitiveSwitch.Root
|
||||
className={classNames(
|
||||
"block h-[14px] w-[14px] rounded-full bg-white transition will-change-transform ltr:translate-x-[4px] rtl:-translate-x-[4px] ltr:[&[data-state='checked']]:translate-x-[17px] rtl:[&[data-state='checked']]:-translate-x-[17px]",
|
||||
props.checked && "shadow-inner",
|
||||
props.thumbProps?.className
|
||||
props.checked ? "bg-gray-900" : "bg-gray-200",
|
||||
primitiveProps.disabled ? "cursor-not-allowed" : "hover:bg-gray-300",
|
||||
"focus:ring-brand-800 h-5 w-[34px] rounded-full shadow-none",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
</PrimitiveSwitch.Root>
|
||||
{label && (
|
||||
<Label.Root
|
||||
htmlFor={id}
|
||||
className={classNames(
|
||||
"align-text-top text-sm font-medium text-gray-900 ltr:ml-3 rtl:mr-3 dark:text-white",
|
||||
primitiveProps.disabled ? "cursor-not-allowed opacity-25" : "cursor-pointer "
|
||||
)}>
|
||||
{label}
|
||||
</Label.Root>
|
||||
)}
|
||||
</div>
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
// Since we dont support global dark mode - we have to style dark mode components specifically on the instance for now
|
||||
// TODO: Remove once we support global dark mode
|
||||
className={classNames(
|
||||
"block h-[14px] w-[14px] rounded-full bg-white transition will-change-transform ltr:translate-x-[4px] rtl:-translate-x-[4px] ltr:[&[data-state='checked']]:translate-x-[17px] rtl:[&[data-state='checked']]:-translate-x-[17px]",
|
||||
props.checked && "shadow-inner",
|
||||
props.thumbProps?.className
|
||||
)}
|
||||
/>
|
||||
</PrimitiveSwitch.Root>
|
||||
{label && (
|
||||
<Label.Root
|
||||
htmlFor={id}
|
||||
className={classNames(
|
||||
"align-text-top text-sm font-medium text-gray-900 ltr:ml-3 rtl:mr-3 dark:text-white",
|
||||
primitiveProps.disabled ? "cursor-not-allowed opacity-25" : "cursor-pointer "
|
||||
)}>
|
||||
{label}
|
||||
</Label.Root>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
|
|||
return null;
|
||||
}
|
||||
const commonClass =
|
||||
"mb-2 inline-flex items-center justify-center rounded-md py-[10px] px-4 text-sm font-medium leading-4 md:mb-0";
|
||||
"w-full inline-flex items-center justify-center rounded py-[10px] px-4 text-sm font-medium leading-4";
|
||||
const selectedClass = classNames(commonClass, "bg-gray-200 text-gray-900");
|
||||
const unselectedClass = classNames(commonClass, "text-gray-600 hover:bg-gray-100 hover:text-gray-900");
|
||||
return (
|
||||
|
@ -40,7 +40,7 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
|
|||
value={yesNoValue}
|
||||
type="single"
|
||||
disabled={disabled}
|
||||
className="space-x-2 rounded-sm rtl:space-x-reverse"
|
||||
className="flex space-x-2 rounded-md border border-gray-200 p-1 rtl:space-x-reverse"
|
||||
onValueChange={(yesNoValue: "yes" | "no") => {
|
||||
setYesNoValue(yesNoValue);
|
||||
onValueChange(boolean(yesNoValue));
|
||||
|
|
|
@ -1,46 +1,24 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { Props } from "react-phone-number-input/react-hook-form";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
|
||||
import { FiMapPin } from "../components/icon";
|
||||
|
||||
type BookingFormValues = {
|
||||
name: string;
|
||||
email: string;
|
||||
notes?: string;
|
||||
locationType?: EventLocationType["type"];
|
||||
guests?: { email: 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
|
||||
customInputs?: {
|
||||
[key: string]: string | boolean;
|
||||
};
|
||||
rescheduleReason?: string;
|
||||
smsReminderNumber?: string;
|
||||
export type AddressInputProps = {
|
||||
value: string;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
onChange: (val: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type AddressInputProps<FormValues> = Props<
|
||||
{
|
||||
value: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
bookingForm: UseFormReturn<BookingFormValues>;
|
||||
},
|
||||
FormValues
|
||||
>;
|
||||
|
||||
function AddressInput<FormValues>({ bookingForm, name, className, ...rest }: AddressInputProps<FormValues>) {
|
||||
function AddressInput({ className = "", value, onChange, ...rest }: AddressInputProps) {
|
||||
return (
|
||||
<div className="relative ">
|
||||
<FiMapPin color="#D2D2D2" className="absolute top-1/2 left-0.5 ml-3 h-6 -translate-y-1/2" />
|
||||
<input
|
||||
{...rest}
|
||||
{...bookingForm.register("attendeeAddress")}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
color="#D2D2D2"
|
||||
className={`${className} focus-within:border-brand dark:bg-darkgray-100 dark:border-darkgray-300 block h-10 w-full rounded-md border border border-gray-300 py-px pl-10 text-sm outline-none ring-black focus-within:ring-1 disabled:text-gray-500 disabled:opacity-50 dark:text-white dark:placeholder-gray-500 dark:selection:bg-green-500 disabled:dark:text-gray-500`}
|
||||
/>
|
||||
|
|
|
@ -1,34 +1,27 @@
|
|||
import { isSupportedCountry } from "libphonenumber-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Props } from "react-phone-number-input/react-hook-form";
|
||||
import BasePhoneInput from "react-phone-number-input/react-hook-form";
|
||||
import BasePhoneInput from "react-phone-number-input";
|
||||
import type { Props, Country } from "react-phone-number-input";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
export type PhoneInputProps<FormValues> = Props<
|
||||
{
|
||||
value: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
},
|
||||
FormValues
|
||||
> & { onChange?: (e: any) => void };
|
||||
export type PhoneInputProps = Props<{
|
||||
value: string;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
|
||||
function PhoneInput<FormValues>({
|
||||
control,
|
||||
name,
|
||||
className,
|
||||
onChange,
|
||||
...rest
|
||||
}: PhoneInputProps<FormValues>) {
|
||||
function PhoneInput({ name, className = "", onChange, ...rest }: PhoneInputProps) {
|
||||
const defaultCountry = useDefaultCountry();
|
||||
|
||||
return (
|
||||
<BasePhoneInput
|
||||
{...rest}
|
||||
international
|
||||
defaultCountry={defaultCountry}
|
||||
name={name}
|
||||
control={control}
|
||||
onChange={onChange}
|
||||
countrySelectProps={{ className: "text-black" }}
|
||||
numberInputProps={{
|
||||
|
@ -40,7 +33,7 @@ function PhoneInput<FormValues>({
|
|||
}
|
||||
|
||||
const useDefaultCountry = () => {
|
||||
const [defaultCountry, setDefaultCountry] = useState("US");
|
||||
const [defaultCountry, setDefaultCountry] = useState<Country>("US");
|
||||
useEffect(() => {
|
||||
fetch("/api/countrycode")
|
||||
.then((res) => res.json())
|
||||
|
|
Loading…
Reference in New Issue
Block a user