import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useEffect, useState } from "react"; import type { SubmitHandler, UseFormReturn } from "react-hook-form"; import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-form"; import type { z } from "zod"; import { ZodError } from "zod"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import { markdownToSafeHTMLClient } from "@calcom/lib/markdownToSafeHTMLClient"; import turndown from "@calcom/lib/turndownService"; import { Badge, BooleanToggleGroupField, Button, CheckboxField, Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, Form, Icon, Input, InputField, Label, SelectField, showToast, Editor, Switch, } from "@calcom/ui"; import { fieldTypesConfigMap } from "./fieldTypes"; import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml"; import { type fieldsSchema, excludeOrRequireEmailSchema } from "./schema"; import { getFieldIdentifier } from "./utils/getFieldIdentifier"; import { getConfig as getVariantsConfig } from "./utils/variantsConfig"; type RhfForm = { fields: z.infer; }; type RhfFormFields = RhfForm["fields"]; type RhfFormField = RhfFormFields[number]; function getCurrentFieldType(fieldForm: UseFormReturn) { return fieldTypesConfigMap[fieldForm.watch("type") || "text"]; } /** * 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, shouldConsiderRequired, }: { 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< string, { source: { label: string }; value: { label: string; value: string; inputPlaceholder?: string }[]; } >; }; /** * This is kind of a hack to allow certain fields to be just shown as required when they might not be required in a strict sense * e.g. Location field has a default value at backend so API can send no location but formBuilder in UI doesn't allow it. */ shouldConsiderRequired?: (field: RhfFormField) => boolean | undefined; }) { // 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 [parent] = useAutoAnimate(); const { t } = useLocale(); 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", }); const [fieldDialog, setFieldDialog] = useState({ isOpen: false, fieldIndex: -1, data: {} as RhfFormField | null, }); const addField = () => { setFieldDialog({ isOpen: true, fieldIndex: -1, data: null, }); }; const editField = (index: number, data: RhfFormField) => { setFieldDialog({ isOpen: true, fieldIndex: index, data, }); }; const removeField = (index: number) => { remove(index); }; return (
{title} {LockedIcon}

{description}

    {fields.map((field, index) => { let options = field.options ?? null; const sources = [...(field.sources || [])]; const isRequired = shouldConsiderRequired ? shouldConsiderRequired(field) : field.required; if (!options && field.getOptionsAt) { const { source: { label: sourceLabel }, value, } = dataStore.options[field.getOptionsAt as keyof typeof dataStore] ?? []; options = value; options.forEach((option) => { sources.push({ id: option.value, label: sourceLabel, type: "system", }); }); } if (fieldsThatSupportLabelAsSafeHtml.includes(field.type)) { field = { ...field, labelAsSafeHtml: markdownToSafeHTMLClient(field.label ?? "") }; } const numOptions = options?.length ?? 0; const firstOptionInput = field.optionsInputs?.[options?.[0]?.value as keyof typeof field.optionsInputs]; const doesFirstOptionHaveInput = !!firstOptionInput; // If there is only one option and it doesn't have an input required, we don't show the Field for it. // Because booker doesn't see this in UI, there is no point showing it in FormBuilder to configure it. if (field.hideWhenJustOneOption && numOptions <= 1 && !doesFirstOptionHaveInput) { return null; } const fieldType = fieldTypesConfigMap[field.type]; const isFieldEditableSystemButOptional = field.editable === "system-but-optional"; const isFieldEditableSystemButHidden = field.editable === "system-but-hidden"; const isFieldEditableSystem = field.editable === "system"; const isUserField = !isFieldEditableSystem && !isFieldEditableSystemButOptional && !isFieldEditableSystemButHidden; if (!fieldType) { throw new Error(`Invalid field type - ${field.type}`); } 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.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 && !isFieldEditableSystemButHidden && !disabled && ( { update(index, { ...field, hidden: !checked }); }} tooltip={t("show_on_booking_page")} /> )} {isUserField && (
    )}
  • ); })}
{!disabled && ( )}
{/* Move this Dialog in another component and it would take with it fieldForm */} {fieldDialog.isOpen && ( setFieldDialog({ isOpen, fieldIndex: -1, data: null, }) } handleSubmit={(data: Parameters>[0]) => { const type = data.type || "text"; const isNewField = !fieldDialog.data; if (isNewField && fields.some((f) => f.name === data.name)) { showToast(t("form_builder_field_already_exists"), "error"); return; } if (fieldDialog.data) { 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, data: null, }); }} shouldConsiderRequired={shouldConsiderRequired} /> )}
); }; function Options({ 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", }, ]); } return (
    {value?.map((option, index) => (
  • { // 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.trim(), }); onChange(value); }} readOnly={readOnly} placeholder={`Enter Option ${index + 1}`} /> {value.length > 2 && !readOnly && (
  • ))}
{!readOnly && ( )}
); } const CheckboxFieldLabel = ({ fieldForm }: { fieldForm: UseFormReturn }) => { const { t } = useLocale(); const [firstRender, setFirstRender] = useState(true); return (
md.render(fieldForm.getValues("label") || "")} setText={(value: string) => { fieldForm.setValue("label", turndown(value), { shouldDirty: true }); }} excludedToolbarItems={["blockType", "bold", "italic"]} disableLists firstRender={firstRender} setFirstRender={setFirstRender} placeholder={t(fieldForm.getValues("defaultLabel") || "")} />
); }; function FieldEditDialog({ dialog, onOpenChange, handleSubmit, shouldConsiderRequired, }: { dialog: { isOpen: boolean; fieldIndex: number; data: RhfFormField | null }; onOpenChange: (isOpen: boolean) => void; handleSubmit: SubmitHandler; shouldConsiderRequired?: (field: RhfFormField) => boolean | undefined; }) { const { t } = useLocale(); const fieldForm = useForm({ defaultValues: dialog.data || {}, //resolver: zodResolver(fieldSchema), }); const formFieldType = fieldForm.getValues("type"); useEffect(() => { if (!formFieldType) { return; } const variantsConfig = getVariantsConfig({ type: formFieldType, variantsConfig: fieldForm.getValues("variantsConfig"), }); // We need to set the variantsConfig in the RHF instead of using a derived value because RHF won't have the variantConfig for the variant that's not rendered yet. fieldForm.setValue("variantsConfig", variantsConfig); }, [fieldForm]); const isFieldEditMode = !!dialog.data; const fieldType = getCurrentFieldType(fieldForm); const variantsConfig = fieldForm.watch("variantsConfig"); const fieldTypes = Object.values(fieldTypesConfigMap); return (
{ const value = e?.value; if (!value) { return; } fieldForm.setValue("type", value, { shouldDirty: true }); }} value={fieldTypesConfigMap[formFieldType]} options={fieldTypes.filter((f) => !f.systemOnly)} label={t("input_type")} /> {(() => { if (!variantsConfig) { return ( <> { fieldForm.setValue("name", getFieldIdentifier(e.target.value || ""), { shouldDirty: true, }); }} disabled={ fieldForm.getValues("editable") === "system" || fieldForm.getValues("editable") === "system-but-optional" } label={t("identifier")} />
{formFieldType === "boolean" ? ( ) : ( )}
{fieldType?.isTextType ? ( ) : null} {fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? ( { return ; }} /> ) : null} {!!fieldType?.supportsLengthCheck ? ( ) : null} {formFieldType === "email" && ( { try { excludeOrRequireEmailSchema.parse(e.target.value); fieldForm.clearErrors("requireEmails"); } catch (err) { if (err instanceof ZodError) { fieldForm.setError("requireEmails", { message: err.errors[0]?.message || "Invalid input", }); } } }} label={t("require_emails_that_contain")} placeholder="gmail.com, hotmail.com, ..." /> )} {formFieldType === "email" && ( { try { excludeOrRequireEmailSchema.parse(e.target.value); fieldForm.clearErrors("excludeEmails"); } catch (err) { if (err instanceof ZodError) { fieldForm.setError("excludeEmails", { message: err.errors[0]?.message || "Invalid input", }); } } }} label={t("exclude_emails_that_contain")} placeholder="gmail.com, hotmail.com, ..." /> )} { const isRequired = shouldConsiderRequired ? shouldConsiderRequired(fieldForm.getValues()) : value; return ( { onChange(val); }} label={t("required")} /> ); }} /> ); } if (!fieldType.isTextType) { throw new Error("Variants are currently supported only with text type"); } return ; })()}
{t("cancel")}
); } function FieldWithLengthCheckSupport({ fieldForm, containerClassName = "", className, ...rest }: { fieldForm: UseFormReturn; containerClassName?: string; } & React.HTMLAttributes) { const { t } = useLocale(); const fieldType = getCurrentFieldType(fieldForm); if (!fieldType.supportsLengthCheck) { return null; } const supportsLengthCheck = fieldType.supportsLengthCheck; const maxAllowedMaxLength = supportsLengthCheck.maxLength; return (
{ fieldForm.setValue("minLength", parseInt(e.target.value ?? 0)); // Ensure that maxLength field adjusts its restrictions fieldForm.trigger("maxLength"); }} min={0} max={fieldForm.getValues("maxLength") || maxAllowedMaxLength} /> { if (!supportsLengthCheck) { return; } fieldForm.setValue("maxLength", parseInt(e.target.value ?? maxAllowedMaxLength)); // Ensure that minLength field adjusts its restrictions fieldForm.trigger("minLength"); }} min={fieldForm.getValues("minLength") || 0} max={maxAllowedMaxLength} />
); } /** * Shows the label of the field, taking into account the current variant selected */ function FieldLabel({ field }: { field: RhfFormField }) { const { t } = useLocale(); const fieldTypeConfig = fieldTypesConfigMap[field.type]; const fieldTypeConfigVariantsConfig = fieldTypeConfig?.variantsConfig; const fieldTypeConfigVariants = fieldTypeConfigVariantsConfig?.variants; const variantsConfig = field.variantsConfig; const variantsConfigVariants = variantsConfig?.variants; const defaultVariant = fieldTypeConfigVariantsConfig?.defaultVariant; if (!fieldTypeConfigVariants || !variantsConfig) { if (fieldsThatSupportLabelAsSafeHtml.includes(field.type)) { return ( ); } else { return {field.label || t(field.defaultLabel || "")}; } } const variant = field.variant || defaultVariant; if (!variant) { throw new Error( `Field has \`variantsConfig\` but no \`defaultVariant\`${JSON.stringify(fieldTypeConfigVariantsConfig)}` ); } const label = variantsConfigVariants?.[variant as keyof typeof fieldTypeConfigVariants]?.fields?.[0]?.label || ""; return {t(label)}; } function VariantSelector() { // Implement a Variant selector for cases when there are more than 2 variants return null; } function VariantFields({ fieldForm, variantsConfig, }: { fieldForm: UseFormReturn; variantsConfig: RhfFormField["variantsConfig"]; }) { const { t } = useLocale(); if (!variantsConfig) { throw new Error("VariantFields component needs variantsConfig"); } const fieldTypeConfigVariantsConfig = fieldTypesConfigMap[fieldForm.getValues("type")]?.variantsConfig; if (!fieldTypeConfigVariantsConfig) { throw new Error("Configuration Issue: FieldType doesn't have `variantsConfig`"); } const variantToggleLabel = t(fieldTypeConfigVariantsConfig.toggleLabel || ""); const defaultVariant = fieldTypeConfigVariantsConfig.defaultVariant; const variantNames = Object.keys(variantsConfig.variants); const otherVariants = variantNames.filter((v) => v !== defaultVariant); if (otherVariants.length > 1 && variantToggleLabel) { throw new Error("More than one other variant. Remove toggleLabel "); } const otherVariant = otherVariants[0]; const variantName = fieldForm.watch("variant") || defaultVariant; const variantFields = variantsConfig.variants[variantName as keyof typeof variantsConfig].fields; /** * A variant that has just one field can be shown in a simpler way in UI. */ const isSimpleVariant = variantFields.length === 1; const isDefaultVariant = variantName === defaultVariant; const supportsVariantToggle = variantNames.length === 2; return ( <> {supportsVariantToggle ? ( { fieldForm.setValue("variant", checked ? otherVariant : defaultVariant); }} classNames={{ container: "mt-2" }} tooltip={t("Toggle Variant")} /> ) : ( )}
    {variantFields.map((f, index) => { const rhfVariantFieldPrefix = `variantsConfig.variants.${variantName}.fields.${index}` as const; const fieldTypeConfigVariants = fieldTypeConfigVariantsConfig.variants[ variantName as keyof typeof fieldTypeConfigVariantsConfig.variants ]; const appUiFieldConfig = fieldTypeConfigVariants.fieldsMap[f.name as keyof typeof fieldTypeConfigVariants.fieldsMap]; return (
  • {!isSimpleVariant && ( )} { return ( { onChange(val); }} label={t("required")} /> ); }} />
  • ); })}
); }