* feat:add prefilcheck check box in the field creation * chore:formatted the li8 translations * feat:add e2e test case for this feature * refactor:reanmed the key from prefilledCheck to disableOnPrefill and moved the readOnly logic to formbuilderField * chore:fixed the type check * fix:if prefilled has error. do not disabble it * chore:removed the e2e tests for prefill in e2e * chore:revert back to old changes * chore:remove unwanted code frome2e * Support name field as well * refactor:handled the feedback changes. still unit test cases pending * feat: add unit tests for disable field on prefill logic * chore: resolved the type check and typos * chore:renamed the files and type clean up * Apply suggestions from code review * chore:renaming test files and moving the disableonprefill hook to formbuilderFeild. * refactor: add unit test for serach params and form values check and minor code formatting and file name changes * fix: validating the input value partially at the intialising phase. so that form response values and from ui values are same and making sure invalid data not sent to backend on submit. * chore:fix the type check issue * fix: field value valdiation at initilisation and add the unit test cases for that change * chore:add comments to the tests * chore:resolve type check * chore: moved the tests to the formBuilder * Simplify codebase by ensuring select and multiselect changes the value when no option is found * Relocate and refactor tests * handle special cases * Dont disable for dirty fields * Update schema.prisma --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Hariom <hariombalhara@gmail.com>
800 lines
30 KiB
TypeScript
800 lines
30 KiB
TypeScript
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 { classNames } from "@calcom/lib";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
|
import {
|
|
Badge,
|
|
BooleanToggleGroupField,
|
|
Button,
|
|
CheckboxField,
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
Form,
|
|
Icon,
|
|
Input,
|
|
InputField,
|
|
Label,
|
|
SelectField,
|
|
showToast,
|
|
Switch,
|
|
} from "@calcom/ui";
|
|
|
|
import { fieldTypesConfigMap } from "./fieldTypes";
|
|
import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml";
|
|
import type { fieldsSchema } from "./schema";
|
|
import { getFieldIdentifier } from "./utils/getFieldIdentifier";
|
|
import { getConfig as getVariantsConfig } from "./utils/variantsConfig";
|
|
|
|
type RhfForm = {
|
|
fields: z.infer<typeof fieldsSchema>;
|
|
};
|
|
|
|
type RhfFormFields = RhfForm["fields"];
|
|
|
|
type RhfFormField = RhfFormFields[number];
|
|
|
|
function getCurrentFieldType(fieldForm: UseFormReturn<RhfFormField>) {
|
|
return fieldTypesConfigMap[fieldForm.watch("type") || "text"];
|
|
}
|
|
|
|
function isRequiredField(field: RhfFormField) {
|
|
// 'location' must be shown as required in the UI(but at backend it is not required because not specifying it defaults to CalVideo)
|
|
// So, handle it in UI only instead of updating bookingFields config in getBookingFields
|
|
// TODO: FormBuilder must not be made aware of BookingFields, so move this "location" check to outside FormBuilder where the component is used.
|
|
return field.name === "location" ? true : field.required;
|
|
}
|
|
|
|
/**
|
|
* 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 [parent] = useAutoAnimate<HTMLUListElement>();
|
|
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 leading-none ltr:mr-1 rtl:ml-1">
|
|
{title}
|
|
{LockedIcon}
|
|
</div>
|
|
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
|
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
|
|
{fields.map((field, index) => {
|
|
let options = field.options ?? null;
|
|
const sources = [...(field.sources || [])];
|
|
const isRequired = isRequiredField(field);
|
|
if (!options && field.getOptionsAt) {
|
|
options = dataStore.options[field.getOptionsAt as keyof typeof dataStore] ?? [];
|
|
// TODO: The dataStore should itself provide the label directly
|
|
// This is important because FormBuilder isn't aware of what a location is. It is a generic Form Builder
|
|
const sourceLabel = field.getOptionsAt === "locations" ? "Location" : field.getOptionsAt;
|
|
options.forEach((option) => {
|
|
sources.push({
|
|
id: option.value,
|
|
label: sourceLabel,
|
|
type: "system",
|
|
});
|
|
});
|
|
}
|
|
|
|
if (fieldsThatSupportLabelAsSafeHtml.includes(field.type)) {
|
|
field = { ...field, labelAsSafeHtml: markdownToSafeHTML(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<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-subtle 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)}>
|
|
<Icon name="arrow-up" className="h-5 w-5" />
|
|
</button>
|
|
)}
|
|
{index < fields.length - 1 && (
|
|
<button
|
|
type="button"
|
|
className="bg-default text-muted hover:border-emphasis border-subtle 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)}>
|
|
<Icon name="arrow-down" 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" data-testid={isRequired ? "required" : "optional"}>
|
|
{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
|
|
data-testid="delete-field-action"
|
|
color="destructive"
|
|
disabled={!isUserField}
|
|
variant="icon"
|
|
onClick={() => {
|
|
removeField(index);
|
|
}}
|
|
StartIcon="trash-2"
|
|
/>
|
|
)}
|
|
<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 = getCurrentFieldType(fieldForm);
|
|
|
|
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}
|
|
data-testid="test-field-type"
|
|
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, { shouldDirty: true });
|
|
}}
|
|
value={fieldTypesConfigMap[fieldForm.getValues("type")]}
|
|
options={fieldTypes.filter((f) => !f.systemOnly)}
|
|
label={t("input_type")}
|
|
/>
|
|
{(() => {
|
|
if (!variantsConfig) {
|
|
return (
|
|
<>
|
|
<InputField
|
|
required
|
|
{...fieldForm.register("name")}
|
|
containerClassName="mt-6"
|
|
onChange={(e) => {
|
|
fieldForm.setValue("name", getFieldIdentifier(e.target.value || ""), {
|
|
shouldDirty: true,
|
|
});
|
|
}}
|
|
disabled={
|
|
fieldForm.getValues("editable") === "system" ||
|
|
fieldForm.getValues("editable") === "system-but-optional"
|
|
}
|
|
label={t("identifier")}
|
|
/>
|
|
<CheckboxField
|
|
description={t("disable_input_if_prefilled")}
|
|
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
|
|
/>
|
|
<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}
|
|
|
|
{!!fieldType?.supportsLengthCheck ? (
|
|
<FieldWithLengthCheckSupport containerClassName="mt-6" fieldForm={fieldForm} />
|
|
) : null}
|
|
|
|
<Controller
|
|
name="required"
|
|
control={fieldForm.control}
|
|
render={({ field: { onChange } }) => {
|
|
const isRequired = isRequiredField(fieldForm.getValues());
|
|
return (
|
|
<BooleanToggleGroupField
|
|
data-testid="field-required"
|
|
disabled={fieldForm.getValues("editable") === "system"}
|
|
value={isRequired}
|
|
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>
|
|
);
|
|
}
|
|
|
|
function FieldWithLengthCheckSupport({
|
|
fieldForm,
|
|
containerClassName = "",
|
|
className,
|
|
...rest
|
|
}: {
|
|
fieldForm: UseFormReturn<RhfFormField>;
|
|
containerClassName?: string;
|
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
|
const { t } = useLocale();
|
|
const fieldType = getCurrentFieldType(fieldForm);
|
|
if (!fieldType.supportsLengthCheck) {
|
|
return null;
|
|
}
|
|
const supportsLengthCheck = fieldType.supportsLengthCheck;
|
|
const maxAllowedMaxLength = supportsLengthCheck.maxLength;
|
|
|
|
return (
|
|
<div className={classNames("grid grid-cols-2 gap-4", className)} {...rest}>
|
|
<InputField
|
|
{...fieldForm.register("minLength", {
|
|
valueAsNumber: true,
|
|
})}
|
|
defaultValue={0}
|
|
containerClassName={containerClassName}
|
|
label={t("min_characters")}
|
|
type="number"
|
|
onChange={(e) => {
|
|
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}
|
|
/>
|
|
<InputField
|
|
{...fieldForm.register("maxLength", {
|
|
valueAsNumber: true,
|
|
})}
|
|
defaultValue={maxAllowedMaxLength}
|
|
containerClassName={containerClassName}
|
|
label={t("max_characters")}
|
|
type="number"
|
|
onChange={(e) => {
|
|
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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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")}
|
|
/>
|
|
|
|
<CheckboxField
|
|
description={t("disable_input_if_prefilled")}
|
|
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
|
|
/>
|
|
|
|
<ul
|
|
className={classNames(
|
|
!isSimpleVariant ? "border-subtle 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>
|
|
</>
|
|
);
|
|
}
|