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, Switch, } from "@calcom/ui"; import { ArrowDown, ArrowUp, X, Plus, Trash2, Info } from "@calcom/ui/components/icon"; import { Components } from "./Components"; import type { fieldsSchema } from "./FormBuilderFieldsSchema"; type RhfForm = { fields: z.infer; }; 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, disabled, LockedIcon, dataStore, }: { formProp: string; title: string; description: string; addFieldLabel: string; disabled: boolean; LockedIcon: false | JSX.Element; /** * A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options */ dataStore: { options: Record; }; }) { 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, // This is false currently because we don't want to show the options for Location field right now. It is the only field with type radioInput. // needsOptions: 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(); const { t } = useLocale(); const fieldForm = useForm(); 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, // eslint-disable-next-line @typescript-eslint/no-empty-function 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(); if (!value) { onChange([ { label: "Option 1", value: "Option 1", }, { label: "Option 2", value: "Option 2", }, ]); } const crossButton = (index: number) => { return ( )} ); } 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") || "text"]; const isFieldEditMode = fieldDialog.fieldIndex !== -1; return (
{title} {LockedIcon}

{description}

    {fields.map((field, index) => { const options = field.options ? field.options : field.getOptionsAt ? dataStore.options[field.getOptionsAt as keyof typeof dataStore] : []; const numOptions = options?.length ?? 0; if (field.hideWhenJustOneOption && numOptions <= 1) { return null; } const fieldType = FieldTypesMap[field.type]; const isRequired = field.required; const isFieldEditableSystemButOptional = field.editable === "system-but-optional"; const isFieldEditableSystem = field.editable === "system"; const isUserField = !isFieldEditableSystem && !isFieldEditableSystemButOptional; 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>); return (
  • {!disabled && ( <> {index >= 1 && ( )} {index < fields.length - 1 && ( )} )}
    {field.label || t(field.defaultLabel || "")}
    {field.hidden ? ( // Hidden field can't be required, so we don't need to show the Optional badge {t("hidden")} ) : ( {isRequired ? t("required") : t("optional")} )} {Object.entries(groupedBySourceLabel).map(([sourceLabel, sources], key) => ( // We don't know how to pluralize `sourceLabel` because it can be anything {sources.length} {sources.length === 1 ? sourceLabel : `${sourceLabel}s`} ))}

    {fieldType.label}

    {field.editable !== "user-readonly" && !disabled && (
    {!isFieldEditableSystem && !disabled && ( { update(index, { ...field, hidden: !checked }); }} classNames={{ container: "p-2 hover:bg-subtle rounded" }} tooltip={t("show_on_booking_page")} /> )} {isUserField && (
    )}
  • ); })}
{!disabled && ( )}
setFieldDialog({ isOpen, fieldIndex: -1, }) }>
{ const type = data.type || "text"; 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, type, sources: [ { label: "User", type: "user", id: "user", fieldRequired: data.required, }, ], }; field.editable = field.editable || "user"; append(field); } setFieldDialog({ isOpen: false, fieldIndex: -1, }); }}> { const value = e?.value; if (!value) { return; } fieldForm.setValue("type", value); }} value={FieldTypesMap[fieldForm.getValues("type")]} options={FieldTypes.filter((f) => !f.systemOnly)} label={t("input_type")} classNames={{ menuList: () => "min-h-[27.25rem]", }} /> {fieldType?.isTextType ? ( ) : null} {fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? ( { return ; }} /> ) : null} {/* TODO: Maybe we should show location options in readOnly mode in Booking Questions. Right now options are not shown in Manage Booking Questions UI for location Booking Question */} {/* {fieldForm.getValues("getOptionsAt") ? ( ) : null} */} { return ( { onChange(val); }} label={t("required")} /> ); }} />
{t("cancel")}
); }; // 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; readOnly: boolean; children: React.ReactNode; }) => { return (
{/* 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 && (
)} {children}
); }; 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 & { // Label is optional because radioInput doesn't have a label label?: string; }; readOnly: boolean; } & ValueProps) => { const fieldType = field.type || "text"; 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 ( void} /> ); } if (componentConfig.propsType === "boolean") { return ( void} placeholder={field.placeholder} /> ); } if (componentConfig.propsType === "textList") { return ( void} /> ); } if (componentConfig.propsType === "select") { if (!field.options) { throw new Error("Field options is not defined"); } return ( void} options={field.options.map((o) => ({ ...o, title: o.label }))} /> ); } if (componentConfig.propsType === "multiselect") { if (!field.options) { throw new Error("Field options is not defined"); } return ( void} options={field.options.map((o) => ({ ...o, title: o.label }))} /> ); } 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 ? ( void} optionsInputs={field.optionsInputs} options={field.options} required={field.required} /> ) : 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 (
{ return (
{ onChange(val); }} /> { message = message || ""; // If the error comes due to parsing the `responses` object(which can have error for any field), we need to identify the field that has the error from the message const name = message.replace(/\{([^}]+)\}.*/, "$1"); const isResponsesErrorForThisField = name === field.name; // If the error comes for the specific property of responses(Possible for system fields), then also we would go ahead and show the error if (!isResponsesErrorForThisField && !error) { return null; } message = message.replace(/\{[^}]+\}(.*)/, "$1").trim(); if (field.hidden) { console.error(`Error message for hidden field:${field.name} => ${message}`); } return (

{t(message || "invalid_input")}

); }} />
); }} />
); };