import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useState, useEffect } from "react"; import { Controller, useFieldArray, useFormContext, useForm } from "react-hook-form"; import type { UseFormReturn, SubmitHandler } from "react-hook-form"; import type { z } from "zod"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { Label, Badge, Button, Dialog, DialogClose, DialogContent, DialogHeader, DialogFooter, Form, BooleanToggleGroupField, SelectField, InputField, Input, Switch, showToast, } from "@calcom/ui"; import { ArrowDown, ArrowUp, X, Plus, Trash2 } from "@calcom/ui/components/icon"; import { fieldTypesConfigMap } from "./fieldTypes"; import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml"; import type { fieldsSchema } from "./schema"; import { getVariantsConfig } from "./utils"; import { getFieldIdentifier } from "./utils/getFieldIdentifier"; 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; }; }) { // 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) => { 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 = fieldTypesConfigMap[field.type]; const isRequired = field.required; 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 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.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 }); }} classNames={{ container: "p-2 hover:bg-subtle rounded" }} 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, }); }} /> )}
); }; 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 && ( )}
); } function FieldEditDialog({ dialog, onOpenChange, handleSubmit, }: { dialog: { isOpen: boolean; fieldIndex: number; data: RhfFormField | null }; onOpenChange: (isOpen: boolean) => void; handleSubmit: SubmitHandler; }) { const { t } = useLocale(); const fieldForm = useForm({ defaultValues: dialog.data || {}, // resolver: zodResolver(fieldSchema), }); useEffect(() => { if (!fieldForm.getValues("type")) { return; } const variantsConfig = getVariantsConfig({ type: fieldForm.getValues("type"), 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 = fieldTypesConfigMap[fieldForm.watch("type") || "text"]; const variantsConfig = fieldForm.watch("variantsConfig"); const fieldTypes = Object.values(fieldTypesConfigMap); return (
{ const value = e?.value; if (!value) { return; } fieldForm.setValue("type", value); }} value={fieldTypesConfigMap[fieldForm.getValues("type")]} options={fieldTypes.filter((f) => !f.systemOnly)} label={t("input_type")} /> {(() => { if (!variantsConfig) { return ( <> { fieldForm.setValue("name", getFieldIdentifier(e.target.value || "")); }} disabled={ fieldForm.getValues("editable") === "system" || fieldForm.getValues("editable") === "system-but-optional" } label={t("identifier")} /> {fieldType?.isTextType ? ( ) : null} {fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? ( { return ; }} /> ) : null} { return ( { onChange(val); }} label={t("required")} /> ); }} /> ); } if (!fieldType.isTextType) { throw new Error("Variants are currently supported only with text type"); } return ; })()}
{t("cancel")}
); } /** * 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("Coniguration 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: "p-2 mt-2 sm:hover:bg-muted rounded" }} 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")} /> ); }} />
  • ); })}
); }