cal.pub0.org/packages/features/form-builder/FormBuilder.tsx

692 lines
25 KiB
TypeScript

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<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,
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<string, { label: string; value: string; inputPlaceholder?: string }[]>;
};
}) {
// 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 { 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 (
<div>
<div>
<div className="text-default text-sm font-semibold ltr:mr-1 rtl:ml-1">
{title}
{LockedIcon}
</div>
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">{description}</p>
<ul className="border-default divide-subtle mt-2 divide-y rounded-md border">
{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<string, NonNullable<(typeof field)["sources"]>>);
return (
<li
key={field.name}
data-testid={`field-${field.name}`}
className="hover:bg-muted group relative flex items-center justify-between p-4 ">
{!disabled && (
<>
{index >= 1 && (
<button
type="button"
className="bg-default text-muted hover:text-emphasis disabled:hover:text-muted border-default hover:border-emphasis invisible absolute -left-[12px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:shadow disabled:hover:border-inherit disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index - 1)}>
<ArrowUp className="h-5 w-5" />
</button>
)}
{index < fields.length - 1 && (
<button
type="button"
className="bg-default text-muted hover:border-emphasis border-default hover:text-emphasis disabled:hover:text-muted invisible absolute -left-[12px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:shadow disabled:hover:border-inherit disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index + 1)}>
<ArrowDown className="h-5 w-5" />
</button>
)}
</>
)}
<div>
<div className="flex flex-col lg:flex-row lg:items-center">
<div className="text-default text-sm font-semibold ltr:mr-2 rtl:ml-2">
<FieldLabel field={field} />
</div>
<div className="flex items-center space-x-2">
{field.hidden ? (
// Hidden field can't be required, so we don't need to show the Optional badge
<Badge variant="grayWithoutHover">{t("hidden")}</Badge>
) : (
<Badge variant="grayWithoutHover">{isRequired ? t("required") : t("optional")}</Badge>
)}
{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="text-subtle max-w-[280px] break-words pt-1 text-sm sm:max-w-[500px]">
{fieldType.label}
</p>
</div>
{field.editable !== "user-readonly" && !disabled && (
<div className="flex items-center space-x-2">
{!isFieldEditableSystem && !isFieldEditableSystemButHidden && !disabled && (
<Switch
data-testid="toggle-field"
disabled={isFieldEditableSystem}
checked={!field.hidden}
onCheckedChange={(checked) => {
update(index, { ...field, hidden: !checked });
}}
classNames={{ container: "p-2 hover:bg-subtle rounded" }}
tooltip={t("show_on_booking_page")}
/>
)}
{isUserField && (
<Button
color="destructive"
disabled={!isUserField}
variant="icon"
onClick={() => {
removeField(index);
}}
StartIcon={Trash2}
/>
)}
<Button
data-testid="edit-field-action"
color="secondary"
onClick={() => {
editField(index, field);
}}>
{t("edit")}
</Button>
</div>
)}
</li>
);
})}
</ul>
{!disabled && (
<Button
color="minimal"
data-testid="add-field"
onClick={addField}
className="mt-4"
StartIcon={Plus}>
{addFieldLabel}
</Button>
)}
</div>
{/* Move this Dialog in another component and it would take with it fieldForm */}
{fieldDialog.isOpen && (
<FieldEditDialog
dialog={fieldDialog}
onOpenChange={(isOpen) =>
setFieldDialog({
isOpen,
fieldIndex: -1,
data: null,
})
}
handleSubmit={(data: Parameters<SubmitHandler<RhfFormField>>[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,
});
}}
/>
)}
</div>
);
};
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<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="bg-muted rounded-md 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.trim(),
});
onChange(value);
}}
readOnly={readOnly}
placeholder={`Enter Option ${index + 1}`}
/>
{value.length > 2 && !readOnly && (
<Button
type="button"
className="-ml-8 mb-2 hover:!bg-transparent focus:!bg-transparent focus:!outline-none focus:!ring-0"
size="sm"
color="minimal"
StartIcon={X}
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={Plus}>
Add an Option
</Button>
)}
</div>
</div>
);
}
function FieldEditDialog({
dialog,
onOpenChange,
handleSubmit,
}: {
dialog: { isOpen: boolean; fieldIndex: number; data: RhfFormField | null };
onOpenChange: (isOpen: boolean) => void;
handleSubmit: SubmitHandler<RhfFormField>;
}) {
const { t } = useLocale();
const fieldForm = useForm<RhfFormField>({
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 (
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-h-none p-0" data-testid="edit-field-dialog">
<Form id="form-builder" form={fieldForm} handleSubmit={handleSubmit}>
<div className="h-auto max-h-[85vh] overflow-auto px-8 pb-7 pt-8">
<DialogHeader title={t("add_a_booking_question")} subtitle={t("booking_questions_description")} />
<SelectField
defaultValue={fieldTypesConfigMap.text}
id="test-field-type"
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={fieldTypesConfigMap[fieldForm.getValues("type")]}
options={fieldTypes.filter((f) => !f.systemOnly)}
label={t("input_type")}
classNames={{
menuList: () => "min-h-[27.25rem]",
}}
/>
{(() => {
if (!variantsConfig) {
return (
<>
<InputField
required
{...fieldForm.register("name")}
containerClassName="mt-6"
onChange={(e) => {
fieldForm.setValue("name", getFieldIdentifier(e.target.value || ""));
}}
disabled={
fieldForm.getValues("editable") === "system" ||
fieldForm.getValues("editable") === "system-but-optional"
}
label={t("identifier")}
/>
<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={t("label")}
/>
{fieldType?.isTextType ? (
<InputField
{...fieldForm.register("placeholder")}
containerClassName="mt-6"
label={t("placeholder")}
placeholder={t(fieldForm.getValues("defaultPlaceholder") || "")}
/>
) : null}
{fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? (
<Controller
name="options"
render={({ field: { value, onChange } }) => {
return <Options onChange={onChange} value={value} className="mt-6" />;
}}
/>
) : null}
<Controller
name="required"
control={fieldForm.control}
render={({ field: { value, onChange } }) => {
return (
<BooleanToggleGroupField
data-testid="field-required"
disabled={fieldForm.getValues("editable") === "system"}
value={value}
onValueChange={(val) => {
onChange(val);
}}
label={t("required")}
/>
);
}}
/>
</>
);
}
if (!fieldType.isTextType) {
throw new Error("Variants are currently supported only with text type");
}
return <VariantFields variantsConfig={variantsConfig} fieldForm={fieldForm} />;
})()}
</div>
<DialogFooter className="relative rounded px-8" showDivider>
<DialogClose color="secondary">{t("cancel")}</DialogClose>
<Button data-testid="field-add-save" type="submit">
{isFieldEditMode ? t("save") : t("add")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
/**
* 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 (
<span
dangerouslySetInnerHTML={{
// Derive from field.label because label might change in b/w and field.labelAsSafeHtml will not be updated.
__html: markdownToSafeHTML(field.label || "") || t(field.defaultLabel || ""),
}}
/>
);
} else {
return <span>{field.label || t(field.defaultLabel || "")}</span>;
}
}
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 <span>{t(label)}</span>;
}
function VariantSelector() {
// Implement a Variant selector for cases when there are more than 2 variants
return null;
}
function VariantFields({
fieldForm,
variantsConfig,
}: {
fieldForm: UseFormReturn<RhfFormField>;
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 ? (
<Switch
checked={!isDefaultVariant}
label={variantToggleLabel}
data-testid="variant-toggle"
onCheckedChange={(checked) => {
fieldForm.setValue("variant", checked ? otherVariant : defaultVariant);
}}
classNames={{ container: "p-2 mt-2 sm:hover:bg-muted rounded" }}
tooltip={t("Toggle Variant")}
/>
) : (
<VariantSelector />
)}
<InputField
required
{...fieldForm.register("name")}
containerClassName="mt-6"
disabled={
fieldForm.getValues("editable") === "system" ||
fieldForm.getValues("editable") === "system-but-optional"
}
label={t("identifier")}
/>
<ul
className={classNames(
!isSimpleVariant ? "border-default divide-subtle mt-2 divide-y rounded-md border" : ""
)}>
{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 (
<li className={classNames(!isSimpleVariant ? "p-4" : "")} key={f.name}>
{!isSimpleVariant && (
<Label className="flex justify-between">
<span>{`Field ${index + 1}`}</span>
<span className="text-muted">{`${fieldForm.getValues("name")}.${f.name}`}</span>
</Label>
)}
<InputField
{...fieldForm.register(`${rhfVariantFieldPrefix}.label`)}
value={f.label || ""}
placeholder={t(appUiFieldConfig?.defaultLabel || "")}
containerClassName="mt-6"
label={t("label")}
/>
<InputField
{...fieldForm.register(`${rhfVariantFieldPrefix}.placeholder`)}
key={f.name}
value={f.placeholder || ""}
containerClassName="mt-6"
label={t("placeholder")}
placeholder={t(appUiFieldConfig?.defaultPlaceholder || "")}
/>
<Controller
name={`${rhfVariantFieldPrefix}.required`}
control={fieldForm.control}
render={({ field: { onChange } }) => {
return (
<BooleanToggleGroupField
data-testid="field-required"
disabled={!appUiFieldConfig?.canChangeRequirability}
value={f.required}
onValueChange={(val) => {
onChange(val);
}}
label={t("required")}
/>
);
}}
/>
</li>
);
})}
</ul>
</>
);
}