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; }; 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(); 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, 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.toLowerCase().trim(), }); onChange(value); }} readOnly={readOnly} placeholder={`Enter Option ${index + 1}`} /> {value.length > 2 && !readOnly && (
  • ))}
{!readOnly && ( )}
); } 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}

{description}

    {fields.map((field, index) => { const fieldType = FieldTypesMap[field.type]; const isRequired = field.required; 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 (
  • {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" && (
    { update(index, { ...field, hidden: !checked }); }} />
    )}
  • ); })}
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="Input Type" /> {fieldType?.isTextType ? ( ) : null} {fieldType?.needsOptions ? ( { return ; }} /> ) : null} { return ( { onChange(val); }} label={t("required")} /> ); }} /> 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 && (
{!readOnly && field.required ? "*" : ""}
)} {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} /> ) : 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); }} /> { 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 (

{t(message)}

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