first commit
This commit is contained in:
516
calcom/packages/features/form-builder/Components.tsx
Normal file
516
calcom/packages/features/form-builder/Components.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import { useEffect } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type {
|
||||
SelectLikeComponentProps,
|
||||
TextLikeComponentProps,
|
||||
} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets";
|
||||
import Widgets from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
AddressInput,
|
||||
Button,
|
||||
CheckboxField,
|
||||
EmailField,
|
||||
Group,
|
||||
Icon,
|
||||
InfoBadge,
|
||||
InputField,
|
||||
Label,
|
||||
PhoneInput,
|
||||
RadioField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ComponentForField } from "./FormBuilderField";
|
||||
import { propsTypes } from "./propsTypes";
|
||||
import type { fieldSchema, FieldType, variantsConfigSchema } from "./schema";
|
||||
import { preprocessNameFieldDataWithVariant } from "./utils";
|
||||
|
||||
export const isValidValueProp: Record<Component["propsType"], (val: unknown) => boolean> = {
|
||||
boolean: (val) => typeof val === "boolean",
|
||||
multiselect: (val) => val instanceof Array && val.every((v) => typeof v === "string"),
|
||||
objectiveWithInput: (val) => (typeof val === "object" && val !== null ? "value" in val : false),
|
||||
select: (val) => typeof val === "string",
|
||||
text: (val) => typeof val === "string",
|
||||
textList: (val) => val instanceof Array && val.every((v) => typeof v === "string"),
|
||||
variants: (val) => (typeof val === "object" && val !== null) || typeof val === "string",
|
||||
};
|
||||
|
||||
type Component =
|
||||
| {
|
||||
propsType: "text";
|
||||
factory: <TProps extends TextLikeComponentProps>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "textList";
|
||||
factory: <TProps extends TextLikeComponentProps<string[]>>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "select";
|
||||
factory: <TProps extends SelectLikeComponentProps>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "boolean";
|
||||
factory: <TProps extends TextLikeComponentProps<boolean>>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "multiselect";
|
||||
factory: <TProps extends SelectLikeComponentProps<string[]>>(props: TProps) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
// Objective type question with option having a possible input
|
||||
propsType: "objectiveWithInput";
|
||||
factory: <
|
||||
TProps extends SelectLikeComponentProps<{
|
||||
value: string;
|
||||
optionValue: string;
|
||||
}> & {
|
||||
optionsInputs: NonNullable<z.infer<typeof fieldSchema>["optionsInputs"]>;
|
||||
value: { value: string; optionValue: string };
|
||||
} & {
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
>(
|
||||
props: TProps
|
||||
) => JSX.Element;
|
||||
}
|
||||
| {
|
||||
propsType: "variants";
|
||||
factory: <
|
||||
TProps extends Omit<TextLikeComponentProps, "value" | "setValue"> & {
|
||||
variant: string | undefined;
|
||||
variants: z.infer<typeof variantsConfigSchema>["variants"];
|
||||
value: Record<string, string> | string | undefined;
|
||||
setValue: (value: string | Record<string, string>) => void;
|
||||
}
|
||||
>(
|
||||
props: TProps
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
// TODO: Share FormBuilder components across react-query-awesome-builder(for Routing Forms) widgets.
|
||||
// There are certain differences b/w two. Routing Forms expect label to be provided by the widget itself and FormBuilder adds label itself and expect no label to be added by component.
|
||||
// Routing Form approach is better as it provides more flexibility to show the label in complex components. But that can't be done right now because labels are missing consistent asterisk required support across different components
|
||||
export const Components: Record<FieldType, Component> = {
|
||||
text: {
|
||||
propsType: propsTypes.text,
|
||||
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
|
||||
},
|
||||
textarea: {
|
||||
propsType: propsTypes.textarea,
|
||||
// TODO: Make rows configurable in the form builder
|
||||
factory: (props) => <Widgets.TextAreaWidget rows={3} {...props} />,
|
||||
},
|
||||
number: {
|
||||
propsType: propsTypes.number,
|
||||
factory: (props) => <Widgets.NumberWidget noLabel={true} {...props} />,
|
||||
},
|
||||
name: {
|
||||
propsType: propsTypes.name,
|
||||
// Keep special "name" type field and later build split(FirstName and LastName) variant of it.
|
||||
factory: (props) => {
|
||||
const { variant: variantName = "fullName" } = props;
|
||||
const onChange = (name: string, value: string) => {
|
||||
let currentValue = props.value;
|
||||
if (typeof currentValue !== "object") {
|
||||
currentValue = {};
|
||||
}
|
||||
props.setValue({
|
||||
...currentValue,
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
if (!props.variants) {
|
||||
throw new Error("'variants' is required for 'name' type of field");
|
||||
}
|
||||
|
||||
if (variantName !== "firstAndLastName" && variantName !== "fullName") {
|
||||
throw new Error(`Invalid variant name '${variantName}' for 'name' type of field`);
|
||||
}
|
||||
|
||||
const value = preprocessNameFieldDataWithVariant(variantName, props.value);
|
||||
|
||||
if (variantName === "fullName") {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Invalid value for 'fullName' variant");
|
||||
}
|
||||
const variant = props.variants[variantName];
|
||||
const variantField = variant.fields[0];
|
||||
return (
|
||||
<InputField
|
||||
name="name"
|
||||
showAsteriskIndicator={true}
|
||||
placeholder={variantField.placeholder}
|
||||
label={variantField.label}
|
||||
containerClassName="w-full"
|
||||
readOnly={props.readOnly}
|
||||
value={value}
|
||||
required={variantField.required}
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
props.setValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = props.variants[variantName];
|
||||
|
||||
if (typeof value !== "object") {
|
||||
throw new Error("Invalid value for 'fullName' variant");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4">
|
||||
{variant.fields.map((variantField) => (
|
||||
<InputField
|
||||
// Because the container is flex(and thus margin is being computed towards container height), I need to explicitly ensure that margin-bottom for the input becomes 0, which is mb-2 otherwise
|
||||
className="!mb-0"
|
||||
showAsteriskIndicator={true}
|
||||
key={variantField.name}
|
||||
name={variantField.name}
|
||||
readOnly={props.readOnly}
|
||||
placeholder={variantField.placeholder}
|
||||
label={variantField.label}
|
||||
containerClassName={`w-full testid-${variantField.name}`}
|
||||
value={value[variantField.name as keyof typeof value]}
|
||||
required={variantField.required}
|
||||
type="text"
|
||||
onChange={(e) => onChange(variantField.name, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
propsType: propsTypes.phone,
|
||||
factory: ({ setValue, readOnly, ...props }) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PhoneInput
|
||||
disabled={readOnly}
|
||||
onChange={(val: string) => {
|
||||
setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
email: {
|
||||
propsType: propsTypes.email,
|
||||
factory: (props) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
return <Widgets.TextWidget type="email" noLabel={true} {...props} />;
|
||||
},
|
||||
},
|
||||
address: {
|
||||
propsType: propsTypes.address,
|
||||
factory: (props) => {
|
||||
return (
|
||||
<AddressInput
|
||||
onChange={(val) => {
|
||||
props.setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
multiemail: {
|
||||
propsType: propsTypes.multiemail,
|
||||
//TODO: Make it a ui component
|
||||
factory: function MultiEmail({ value, readOnly, label, setValue, ...props }) {
|
||||
const placeholder = props.placeholder;
|
||||
const { t } = useLocale();
|
||||
value = value || [];
|
||||
const inputClassName =
|
||||
"dark:placeholder:text-muted focus:border-emphasis border-subtle block w-full rounded-md border-default text-sm focus:ring-black disabled:bg-emphasis disabled:hover:cursor-not-allowed dark:selection:bg-green-500 disabled:dark:text-subtle bg-default";
|
||||
return (
|
||||
<>
|
||||
{value.length ? (
|
||||
<div>
|
||||
<label htmlFor="guests" className="text-default mb-1 block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<ul>
|
||||
{value.map((field, index) => (
|
||||
<li key={index}>
|
||||
<EmailField
|
||||
disabled={readOnly}
|
||||
value={value[index]}
|
||||
className={inputClassName}
|
||||
onChange={(e) => {
|
||||
value[index] = e.target.value;
|
||||
setValue(value);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
label={<></>}
|
||||
required
|
||||
onClickAddon={() => {
|
||||
value.splice(index, 1);
|
||||
setValue(value);
|
||||
}}
|
||||
addOnSuffix={
|
||||
!readOnly ? (
|
||||
<Tooltip content="Remove email">
|
||||
<button className="m-1" type="button">
|
||||
<Icon name="x" width={12} className="text-default" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
data-testid="add-another-guest"
|
||||
type="button"
|
||||
color="minimal"
|
||||
StartIcon="user-plus"
|
||||
className="my-2.5"
|
||||
onClick={() => {
|
||||
value.push("");
|
||||
setValue(value);
|
||||
}}>
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{!value.length && !readOnly && (
|
||||
<Button
|
||||
data-testid="add-guests"
|
||||
color="minimal"
|
||||
variant="button"
|
||||
StartIcon="user-plus"
|
||||
onClick={() => {
|
||||
value.push("");
|
||||
setValue(value);
|
||||
}}
|
||||
className="mr-auto">
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
multiselect: {
|
||||
propsType: propsTypes.multiselect,
|
||||
factory: (props) => {
|
||||
const newProps = {
|
||||
...props,
|
||||
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
|
||||
};
|
||||
return <Widgets.MultiSelectWidget {...newProps} />;
|
||||
},
|
||||
},
|
||||
select: {
|
||||
propsType: propsTypes.select,
|
||||
factory: (props) => {
|
||||
const newProps = {
|
||||
...props,
|
||||
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
|
||||
};
|
||||
return <Widgets.SelectWidget {...newProps} />;
|
||||
},
|
||||
},
|
||||
checkbox: {
|
||||
propsType: propsTypes.checkbox,
|
||||
factory: ({ options, readOnly, setValue, value }) => {
|
||||
value = value || [];
|
||||
return (
|
||||
<div>
|
||||
{options.map((option, i) => {
|
||||
return (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const newValue = value.filter((v) => v !== option.value);
|
||||
if (e.target.checked) {
|
||||
newValue.push(option.value);
|
||||
}
|
||||
setValue(newValue);
|
||||
}}
|
||||
className="border-default dark:border-default hover:bg-subtle checked:hover:bg-brand-default checked:bg-brand-default dark:checked:bg-brand-default dark:bg-darkgray-100 dark:hover:bg-subtle dark:checked:hover:bg-brand-default h-4 w-4 cursor-pointer rounded ltr:mr-2 rtl:ml-2"
|
||||
value={option.value}
|
||||
checked={value.includes(option.value)}
|
||||
/>
|
||||
<span className="text-emphasis me-2 ms-2 text-sm">{option.label ?? ""}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
radio: {
|
||||
propsType: propsTypes.radio,
|
||||
factory: ({ setValue, name, value, options }) => {
|
||||
return (
|
||||
<Group
|
||||
value={value}
|
||||
onValueChange={(e) => {
|
||||
setValue(e);
|
||||
}}>
|
||||
<>
|
||||
{options.map((option, i) => (
|
||||
<RadioField
|
||||
label={option.label}
|
||||
key={`option.${i}.radio`}
|
||||
value={option.label}
|
||||
id={`${name}.option.${i}.radio`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
radioInput: {
|
||||
propsType: propsTypes.radioInput,
|
||||
factory: function RadioInputWithLabel({ name, options, optionsInputs, value, setValue, readOnly }) {
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setValue({
|
||||
value: options[0]?.value,
|
||||
optionValue: "",
|
||||
});
|
||||
}
|
||||
}, [options, setValue, value]);
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const getCleanLabel = (option: { label: string; value: string }): string | JSX.Element => {
|
||||
if (!option.label) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return option.label.search(/^https?:\/\//) !== -1 ? (
|
||||
<a href={option.label} target="_blank">
|
||||
<span className="underline">{option.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
option.label
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{options.length > 1 ? (
|
||||
options.map((option, i) => {
|
||||
return (
|
||||
<label key={i} className="mb-1 flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
className="bg-default after:bg-default border-emphasis focus:ring-brand-default hover:bg-subtle hover:after:bg-subtle dark:checked:after:bg-brand-accent flex h-4 w-4 cursor-pointer items-center justify-center text-[--cal-brand] after:h-[6px] after:w-[6px] after:rounded-full after:content-[''] after:hover:block focus:ring-2 focus:ring-offset-0 ltr:mr-2 rtl:ml-2 dark:checked:hover:text-[--cal-brand]"
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
setValue({
|
||||
value: e.target.value,
|
||||
optionValue: "",
|
||||
});
|
||||
}}
|
||||
checked={value?.value === option.value}
|
||||
/>
|
||||
<span className="text-emphasis me-2 ms-2 text-sm">{getCleanLabel(option) ?? ""}</span>
|
||||
<span>
|
||||
{option.value === "phone" && (
|
||||
<InfoBadge content={t("number_in_international_format")} />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Show option itself as label because there is just one option
|
||||
<>
|
||||
<Label className="flex">
|
||||
{options[0].label}
|
||||
{!readOnly && optionsInputs[options[0].value]?.required ? (
|
||||
<span className="text-default mb-1 ml-1 text-sm font-medium">*</span>
|
||||
) : null}
|
||||
{options[0].value === "phone" && (
|
||||
<InfoBadge content={t("number_in_international_format")} />
|
||||
)}
|
||||
</Label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const optionField = optionsInputs[value?.value];
|
||||
if (!optionField) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
readOnly={!!readOnly}
|
||||
field={{
|
||||
...optionField,
|
||||
name: "optionField",
|
||||
}}
|
||||
value={value?.optionValue}
|
||||
setValue={(val: string | undefined) => {
|
||||
setValue({
|
||||
value: value?.value,
|
||||
optionValue: val || "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
boolean: {
|
||||
propsType: propsTypes.boolean,
|
||||
factory: ({ readOnly, name, label, value, setValue }) => {
|
||||
return (
|
||||
<div className="flex">
|
||||
<CheckboxField
|
||||
name={name}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setValue(true);
|
||||
} else {
|
||||
setValue(false);
|
||||
}
|
||||
}}
|
||||
placeholder=""
|
||||
checked={value}
|
||||
disabled={readOnly}
|
||||
description=""
|
||||
// Form Builder ensures that it would be safe HTML in here if the field type supports it. So, we can safely use label value in `descriptionAsSafeHtml`
|
||||
descriptionAsSafeHtml={label ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
// Should use `statisfies` to check if the `type` is from supported types. But satisfies doesn't work with Next.js config
|
||||
716
calcom/packages/features/form-builder/FormBuilder.tsx
Normal file
716
calcom/packages/features/form-builder/FormBuilder.tsx
Normal file
@@ -0,0 +1,716 @@
|
||||
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 { getAndUpdateNormalizedValues } from "@calcom/features/form-builder/FormBuilderField";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import {
|
||||
Badge,
|
||||
BooleanToggleGroupField,
|
||||
Button,
|
||||
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];
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const options = field.options
|
||||
? field.options
|
||||
: field.getOptionsAt
|
||||
? dataStore.options[field.getOptionsAt as keyof typeof dataStore]
|
||||
: [];
|
||||
|
||||
// Note: We recently started calling getAndUpdateNormalizedValues in the FormBuilder. It was supposed to be called only on booking pages earlier.
|
||||
// Due to this we have to meet some strict requirements like of labelAsSafeHtml.
|
||||
if (fieldsThatSupportLabelAsSafeHtml.includes(field.type)) {
|
||||
field = { ...field, labelAsSafeHtml: markdownToSafeHTML(field.label ?? "") };
|
||||
}
|
||||
|
||||
const { hidden } = getAndUpdateNormalizedValues({ ...field, options }, t);
|
||||
if (field.hideWhenJustOneOption && (hidden || !options?.length)) {
|
||||
return null;
|
||||
}
|
||||
let fieldType = fieldTypesConfigMap[field.type];
|
||||
let isRequired = field.required;
|
||||
// For radioInput type, when there's only one option, the type and required takes the first options values
|
||||
if (field.type === "radioInput" && options.length === 1) {
|
||||
fieldType = fieldTypesConfigMap[field.optionsInputs?.[options[0].value].type || field.type];
|
||||
isRequired = field.optionsInputs?.[options[0].value].required || 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-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={() => {
|
||||
const fieldToEdit = field;
|
||||
// For radioInput type, when there's only one option, the type and required takes the only first options values
|
||||
if (fieldToEdit.type === "radioInput" && options.length === 1) {
|
||||
fieldToEdit.type =
|
||||
fieldToEdit.optionsInputs?.[options[0].value].type || fieldToEdit.type;
|
||||
fieldToEdit.required =
|
||||
fieldToEdit.optionsInputs?.[options[0].value].required || fieldToEdit.required;
|
||||
}
|
||||
|
||||
editField(index, fieldToEdit);
|
||||
}}>
|
||||
{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}
|
||||
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")}
|
||||
/>
|
||||
<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-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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
357
calcom/packages/features/form-builder/FormBuilderField.tsx
Normal file
357
calcom/packages/features/form-builder/FormBuilderField.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, InfoBadge, Label } from "@calcom/ui";
|
||||
|
||||
import { Components, isValidValueProp } from "./Components";
|
||||
import { fieldTypesConfigMap } from "./fieldTypes";
|
||||
import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml";
|
||||
import type { fieldsSchema } from "./schema";
|
||||
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
|
||||
|
||||
type RhfForm = {
|
||||
fields: z.infer<typeof fieldsSchema>;
|
||||
};
|
||||
|
||||
type RhfFormFields = RhfForm["fields"];
|
||||
|
||||
type RhfFormField = RhfFormFields[number];
|
||||
|
||||
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 FormBuilderField = ({
|
||||
field,
|
||||
readOnly,
|
||||
className,
|
||||
}: {
|
||||
field: RhfFormFields[number];
|
||||
readOnly: boolean;
|
||||
className: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, formState } = useFormContext();
|
||||
|
||||
const { hidden, placeholder, label } = getAndUpdateNormalizedValues(field, t);
|
||||
|
||||
return (
|
||||
<div data-fob-field-name={field.name} className={classNames(className, hidden ? "hidden" : "")}>
|
||||
<Controller
|
||||
control={control}
|
||||
// Make it a variable
|
||||
name={`responses.${field.name}`}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
field={{ ...field, label, placeholder, hidden }}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
setValue={(val: unknown) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<ErrorMessage
|
||||
name="responses"
|
||||
errors={formState.errors}
|
||||
render={({ message }: { message: string | undefined }) => {
|
||||
message = message || "";
|
||||
// If the error comes due to parsing the `responses` object(which can have error for any field), we need to identify the field that has the error from the message
|
||||
const name = message.replace(/\{([^}]+)\}.*/, "$1");
|
||||
const isResponsesErrorForThisField = name === field.name;
|
||||
// If the error comes for the specific property of responses(Possible for system fields), then also we would go ahead and show the error
|
||||
if (!isResponsesErrorForThisField && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
message = message.replace(/\{[^}]+\}(.*)/, "$1").trim();
|
||||
|
||||
if (hidden) {
|
||||
console.error(`Error message for hidden field:${field.name} => ${message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`error-message-${field.name}`}
|
||||
className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon name="info" className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t(message || "invalid_input")}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function assertUnreachable(arg: never) {
|
||||
throw new Error(`Don't know how to handle ${JSON.stringify(arg)}`);
|
||||
}
|
||||
|
||||
// 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<RhfFormField>;
|
||||
readOnly: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 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 && (
|
||||
<div className="mb-2 flex items-center">
|
||||
<Label className="!mb-0 flex">
|
||||
<span>{field.label}</span>
|
||||
<span className="text-emphasis -mb-1 ml-1 text-sm font-medium leading-none">
|
||||
{!readOnly && field.required ? "*" : ""}
|
||||
</span>
|
||||
{field.type === "phone" && <InfoBadge content={t("number_in_international_format")} />}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that `labels` and `placeholders`, wherever they are, are set properly. If direct values are not set, default values from fieldTypeConfig are used.
|
||||
*/
|
||||
export function getAndUpdateNormalizedValues(field: RhfFormFields[number], t: TFunction) {
|
||||
let noLabel = false;
|
||||
let hidden = !!field.hidden;
|
||||
if (field.type === "radioInput") {
|
||||
const options = field.options;
|
||||
|
||||
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
|
||||
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
|
||||
if (options?.length === 1) {
|
||||
if (!field.optionsInputs) {
|
||||
throw new Error("radioInput must have optionsInputs");
|
||||
}
|
||||
if (field.optionsInputs[options[0].value]) {
|
||||
noLabel = true;
|
||||
} else {
|
||||
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instead of passing labelAsSafeHtml props to all the components, FormBuilder components can assume that the label is safe html and use it on a case by case basis after adding checks here
|
||||
*/
|
||||
if (fieldsThatSupportLabelAsSafeHtml.includes(field.type) && field.labelAsSafeHtml === undefined) {
|
||||
throw new Error(`${field.name}:${field.type} type must have labelAsSafeHtml set`);
|
||||
}
|
||||
|
||||
const label = noLabel ? "" : field.labelAsSafeHtml || field.label || t(field.defaultLabel || "");
|
||||
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
|
||||
|
||||
if (field.variantsConfig?.variants) {
|
||||
Object.entries(field.variantsConfig.variants).forEach(([variantName, variant]) => {
|
||||
variant.fields.forEach((variantField) => {
|
||||
const fieldTypeVariantsConfig = fieldTypesConfigMap[field.type]?.variantsConfig;
|
||||
const defaultVariantFieldLabel =
|
||||
fieldTypeVariantsConfig?.variants?.[variantName]?.fieldsMap[variantField.name]?.defaultLabel;
|
||||
|
||||
variantField.label = variantField.label || t(defaultVariantFieldLabel || "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { hidden, placeholder, label };
|
||||
}
|
||||
|
||||
export const ComponentForField = ({
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
readOnly,
|
||||
}: {
|
||||
field: Omit<RhfFormField, "editable" | "label"> & {
|
||||
// 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 { t } = useLocale();
|
||||
|
||||
const isValueOfPropsType = (val: unknown, propsType: typeof componentConfig.propsType) => {
|
||||
const isValid = isValidValueProp[propsType](val);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
value={value as string}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "boolean") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
value={value as boolean}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "textList") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
value={value as string[]}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "select") {
|
||||
if (!field.options) {
|
||||
throw new Error("Field options is not defined");
|
||||
}
|
||||
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
readOnly={readOnly}
|
||||
value={value as string}
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
options={field.options.map((o) => ({ ...o, title: o.label }))}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "multiselect") {
|
||||
if (!field.options) {
|
||||
throw new Error("Field options is not defined");
|
||||
}
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
name={field.name}
|
||||
readOnly={readOnly}
|
||||
value={value as string[]}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
options={field.options.map((o) => ({ ...o, title: o.label }))}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const options = field.options;
|
||||
|
||||
return field.options.length ? (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
readOnly={readOnly}
|
||||
name={field.name}
|
||||
value={value as { value: string; optionValue: string }}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
optionsInputs={field.optionsInputs}
|
||||
options={options}
|
||||
required={field.required}
|
||||
/>
|
||||
</WithLabel>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "variants") {
|
||||
const translatedVariantsConfig = getTranslatedVariantsConfig(field, t);
|
||||
if (!translatedVariantsConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
readOnly={readOnly}
|
||||
name={field.name}
|
||||
variant={field.variant}
|
||||
value={value as { value: string; optionValue: string }}
|
||||
setValue={setValue as (arg: Record<string, string> | string) => void}
|
||||
variants={translatedVariantsConfig.variants}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
assertUnreachable(componentConfig);
|
||||
return null;
|
||||
};
|
||||
157
calcom/packages/features/form-builder/fieldTypes.ts
Normal file
157
calcom/packages/features/form-builder/fieldTypes.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type z from "zod";
|
||||
|
||||
import { propsTypes } from "./propsTypes";
|
||||
import type { FieldType, fieldTypeConfigSchema } from "./schema";
|
||||
|
||||
const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "propsType">> = {
|
||||
// This won't be stored in DB. It allows UI to be configured from the codebase for all existing booking fields stored in DB as well
|
||||
// Candidates for this are:
|
||||
// - Anything that you want to show in App UI only.
|
||||
// - Default values that are shown in UI that are supposed to be changed for existing bookingFields as well if user is using default values
|
||||
name: {
|
||||
label: "Name",
|
||||
value: "name",
|
||||
isTextType: true,
|
||||
systemOnly: true,
|
||||
variantsConfig: {
|
||||
toggleLabel: 'Split "Full name" into "First name" and "Last name"',
|
||||
defaultVariant: "fullName",
|
||||
variants: {
|
||||
firstAndLastName: {
|
||||
label: "first_last_name",
|
||||
fieldsMap: {
|
||||
firstName: {
|
||||
defaultLabel: "first_name",
|
||||
canChangeRequirability: false,
|
||||
},
|
||||
lastName: {
|
||||
defaultLabel: "last_name",
|
||||
canChangeRequirability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
fullName: {
|
||||
label: "your_name",
|
||||
fieldsMap: {
|
||||
fullName: {
|
||||
defaultLabel: "your_name",
|
||||
defaultPlaceholder: "example_name",
|
||||
canChangeRequirability: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: {
|
||||
variants: {
|
||||
firstAndLastName: {
|
||||
// Configures variant fields
|
||||
// This array form(in comparison to a generic component form) has the benefit that we can allow configuring placeholder, label, required etc. for each variant
|
||||
// Doing this in a generic component form would require a lot of work in terms of supporting variables maybe that would be read by the component.
|
||||
fields: [
|
||||
{
|
||||
// This name won't be configurable by user. User can always configure the main field name
|
||||
name: "firstName",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "lastName",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
fullName: {
|
||||
fields: [
|
||||
{
|
||||
name: "fullName",
|
||||
type: "text",
|
||||
label: "your_name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
label: "Email",
|
||||
value: "email",
|
||||
isTextType: true,
|
||||
},
|
||||
phone: {
|
||||
label: "Phone",
|
||||
value: "phone",
|
||||
isTextType: true,
|
||||
},
|
||||
address: {
|
||||
label: "Address",
|
||||
value: "address",
|
||||
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,
|
||||
|
||||
// This is false currently because we don't want to show the options for Location field right now. It is the only field with type radioInput.
|
||||
// needsOptions: 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,
|
||||
},
|
||||
};
|
||||
|
||||
export const fieldTypesConfigMap = configMap as Record<FieldType, z.infer<typeof fieldTypeConfigSchema>>;
|
||||
|
||||
Object.entries(fieldTypesConfigMap).forEach(([fieldType, config]) => {
|
||||
config.propsType = propsTypes[fieldType as keyof typeof fieldTypesConfigMap];
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FieldType } from "./schema";
|
||||
|
||||
/**
|
||||
* Once a component supports `labelAsSafeHtml`, add it's field's type here
|
||||
* A whitelist is needed because unless we explicitly use dangerouslySetInnerHTML, React will escape the HTML
|
||||
*/
|
||||
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean"];
|
||||
5
calcom/packages/features/form-builder/index.ts
Normal file
5
calcom/packages/features/form-builder/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// TODO: FormBuilder makes more sense in @calcom/ui but it has an additional thing that other components don't have
|
||||
// It has zod schema associated with it and I currently can't import zod in there.
|
||||
// Move it later there maybe? @sean
|
||||
export { FormBuilder } from "./FormBuilder";
|
||||
export { FormBuilderField } from "./FormBuilderField";
|
||||
16
calcom/packages/features/form-builder/propsTypes.ts
Normal file
16
calcom/packages/features/form-builder/propsTypes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const propsTypes = {
|
||||
name: "variants",
|
||||
email: "text",
|
||||
phone: "text",
|
||||
address: "text",
|
||||
text: "text",
|
||||
number: "text",
|
||||
textarea: "text",
|
||||
select: "select",
|
||||
multiselect: "multiselect",
|
||||
multiemail: "textList",
|
||||
radioInput: "objectiveWithInput",
|
||||
checkbox: "multiselect",
|
||||
radio: "select",
|
||||
boolean: "boolean",
|
||||
} as const;
|
||||
338
calcom/packages/features/form-builder/schema.ts
Normal file
338
calcom/packages/features/form-builder/schema.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getValidRhfFieldName } from "@calcom/lib/getValidRhfFieldName";
|
||||
|
||||
import { fieldTypesConfigMap } from "./fieldTypes";
|
||||
import { preprocessNameFieldDataWithVariant } from "./utils";
|
||||
import { getConfig as getVariantsConfig } from "./utils/variantsConfig";
|
||||
|
||||
const nonEmptyString = () => z.string().refine((value: string) => value.trim().length > 0);
|
||||
|
||||
const fieldTypeEnum = z.enum([
|
||||
"name",
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"multiemail",
|
||||
"select",
|
||||
"multiselect",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"radioInput",
|
||||
"boolean",
|
||||
]);
|
||||
|
||||
export type FieldType = z.infer<typeof fieldTypeEnum>;
|
||||
|
||||
export const EditableSchema = z.enum([
|
||||
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
|
||||
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
|
||||
"system-but-hidden", // Can't be deleted, name can't be edited, will be shown
|
||||
"user", // Fully editable
|
||||
"user-readonly", // All fields are readOnly.
|
||||
]);
|
||||
|
||||
const baseFieldSchema = z.object({
|
||||
name: z.string().transform(getValidRhfFieldName),
|
||||
type: fieldTypeEnum,
|
||||
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
|
||||
label: z.string().optional(),
|
||||
labelAsSafeHtml: z.string().optional(),
|
||||
|
||||
/**
|
||||
* It is the default label that will be used when a new field is created.
|
||||
* Note: It belongs in FieldsTypeConfig, so that changing defaultLabel in code can work for existing fields as well(for fields that are using the default label).
|
||||
* Supports translation
|
||||
*/
|
||||
defaultLabel: z.string().optional(),
|
||||
|
||||
placeholder: z.string().optional(),
|
||||
/**
|
||||
* It is the default placeholder that will be used when a new field is created.
|
||||
* Note: Same as defaultLabel, it belongs in FieldsTypeConfig
|
||||
* Supports translation
|
||||
*/
|
||||
defaultPlaceholder: z.string().optional(),
|
||||
required: z.boolean().default(false).optional(),
|
||||
/**
|
||||
* It is the list of options that is valid for a certain type of fields.
|
||||
*
|
||||
*/
|
||||
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
||||
/**
|
||||
* This is an alternate way to specify options when the options are stored elsewhere. Form Builder expects options to be present at `dataStore[getOptionsAt]`
|
||||
* This allows keeping a single source of truth in DB.
|
||||
*/
|
||||
getOptionsAt: z.string().optional(),
|
||||
|
||||
/**
|
||||
* For `radioInput` type of questions, it stores the input that is shown based on the user option selected.
|
||||
* e.g. If user is given a list of locations and he selects "Phone", then he will be shown a phone input
|
||||
*/
|
||||
optionsInputs: z
|
||||
.record(
|
||||
z.object({
|
||||
// Support all types as needed
|
||||
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
|
||||
type: z.enum(["address", "phone", "text"]),
|
||||
required: z.boolean().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const variantsConfigSchema = z.object({
|
||||
variants: z.record(
|
||||
z.object({
|
||||
/**
|
||||
* Variant Fields schema for a variant of the main field.
|
||||
* It doesn't support non text fields as of now
|
||||
**/
|
||||
fields: baseFieldSchema
|
||||
.omit({
|
||||
defaultLabel: true,
|
||||
defaultPlaceholder: true,
|
||||
options: true,
|
||||
getOptionsAt: true,
|
||||
optionsInputs: true,
|
||||
})
|
||||
.array(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type ALL_VIEWS = "ALL_VIEWS";
|
||||
|
||||
// It is the config that is specific to a type and doesn't make sense in all fields individually. Any field with the type will automatically inherit this config.
|
||||
// This allows making changes to the UI without having to make changes to the existing stored configs
|
||||
export const fieldTypeConfigSchema = z
|
||||
.object({
|
||||
label: z.string(),
|
||||
value: fieldTypeEnum,
|
||||
isTextType: z.boolean().default(false).optional(),
|
||||
systemOnly: z.boolean().default(false).optional(),
|
||||
needsOptions: z.boolean().default(false).optional(),
|
||||
propsType: z.enum([
|
||||
"text",
|
||||
"textList",
|
||||
"select",
|
||||
"multiselect",
|
||||
"boolean",
|
||||
"objectiveWithInput",
|
||||
"variants",
|
||||
]),
|
||||
// It is the config that can tweak what an existing or a new field shows in the App UI or booker UI.
|
||||
variantsConfig: z
|
||||
.object({
|
||||
/**
|
||||
* This is the default variant that will be used when a new field is created.
|
||||
*/
|
||||
defaultVariant: z.string(),
|
||||
|
||||
/**
|
||||
* Used only when there are 2 variants, so that UI can be simplified by showing a switch(with this label) instead of a Select
|
||||
*/
|
||||
toggleLabel: z.string().optional(),
|
||||
variants: z.record(
|
||||
z.object({
|
||||
/**
|
||||
* That's how the variant would be labelled in App UI. This label represents the field in booking questions' list
|
||||
* Supports translation
|
||||
*/
|
||||
label: z.string(),
|
||||
fieldsMap: z.record(
|
||||
z.object({
|
||||
/**
|
||||
* Supports translation
|
||||
*/
|
||||
defaultLabel: z.string().optional(),
|
||||
/**
|
||||
* Supports translation
|
||||
*/
|
||||
defaultPlaceholder: z.string().optional(),
|
||||
/**
|
||||
* Decides if a variant field's required property can be changed or not
|
||||
*/
|
||||
canChangeRequirability: z.boolean().default(true).optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
/**
|
||||
* This is the default configuration for the field.
|
||||
*/
|
||||
defaultValue: variantsConfigSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => {
|
||||
if (!data.variantsConfig) {
|
||||
return;
|
||||
}
|
||||
const variantsConfig = data.variantsConfig;
|
||||
if (!variantsConfig.variants[variantsConfig.defaultVariant]) {
|
||||
throw new Error(`defaultVariant: ${variantsConfig.defaultVariant} is not in variants`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Main field Schema
|
||||
*/
|
||||
export const fieldSchema = baseFieldSchema.merge(
|
||||
z.object({
|
||||
variant: z.string().optional(),
|
||||
variantsConfig: variantsConfigSchema.optional(),
|
||||
|
||||
views: z
|
||||
.object({
|
||||
label: z.string(),
|
||||
id: z.string(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
|
||||
/**
|
||||
* It is used to hide fields such as location when there are less than two options
|
||||
*/
|
||||
hideWhenJustOneOption: z.boolean().default(false).optional(),
|
||||
|
||||
hidden: z.boolean().optional(),
|
||||
editable: EditableSchema.default("user").optional(),
|
||||
sources: z
|
||||
.array(
|
||||
z.object({
|
||||
// Unique ID for the `type`. If type is workflow, it's the workflow ID
|
||||
id: z.string(),
|
||||
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
|
||||
label: z.string(),
|
||||
editUrl: z.string().optional(),
|
||||
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
|
||||
fieldRequired: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export const fieldsSchema = z.array(fieldSchema);
|
||||
|
||||
export const fieldTypesSchemaMap: Partial<
|
||||
Record<
|
||||
FieldType,
|
||||
{
|
||||
/**
|
||||
* - preprocess the responses received through prefill query params
|
||||
* - preprocess the values being filled in the booking form.
|
||||
* - does not run for the responses received from DB
|
||||
*/
|
||||
preprocess: (data: {
|
||||
field: z.infer<typeof fieldSchema>;
|
||||
response: any;
|
||||
isPartialSchema: boolean;
|
||||
}) => unknown;
|
||||
/**
|
||||
* - Validates the response received through prefill query params
|
||||
* - Validates the values being filled in the booking form.
|
||||
* - does not run for the responses received from DB
|
||||
*/
|
||||
superRefine: (data: {
|
||||
field: z.infer<typeof fieldSchema>;
|
||||
response: any;
|
||||
isPartialSchema: boolean;
|
||||
ctx: z.RefinementCtx;
|
||||
m: (key: string) => string;
|
||||
}) => void;
|
||||
}
|
||||
>
|
||||
> = {
|
||||
name: {
|
||||
preprocess: ({ response, field }) => {
|
||||
const fieldTypeConfig = fieldTypesConfigMap[field.type];
|
||||
|
||||
const variantInResponse = field.variant || fieldTypeConfig?.variantsConfig?.defaultVariant;
|
||||
let correctedVariant: "firstAndLastName" | "fullName";
|
||||
|
||||
if (!variantInResponse) {
|
||||
throw new Error("`variant` must be there for the field with `variantsConfig`");
|
||||
}
|
||||
|
||||
if (variantInResponse !== "firstAndLastName" && variantInResponse !== "fullName") {
|
||||
correctedVariant = "fullName";
|
||||
} else {
|
||||
correctedVariant = variantInResponse;
|
||||
}
|
||||
|
||||
return preprocessNameFieldDataWithVariant(correctedVariant, response);
|
||||
},
|
||||
superRefine: ({ field, response, isPartialSchema, ctx, m }) => {
|
||||
const stringSchema = z.string();
|
||||
const fieldTypeConfig = fieldTypesConfigMap[field.type];
|
||||
const variantInResponse = field.variant || fieldTypeConfig?.variantsConfig?.defaultVariant;
|
||||
if (!variantInResponse) {
|
||||
throw new Error("`variant` must be there for the field with `variantsConfig`");
|
||||
}
|
||||
|
||||
const variantsConfig = getVariantsConfig(field);
|
||||
|
||||
if (!variantsConfig) {
|
||||
throw new Error("variantsConfig must be there for `name` field");
|
||||
}
|
||||
|
||||
const fields =
|
||||
variantsConfig.variants[variantInResponse as keyof typeof variantsConfig.variants].fields;
|
||||
|
||||
const variantSupportedFields = ["text"];
|
||||
|
||||
if (fields.length === 1) {
|
||||
const field = fields[0];
|
||||
if (variantSupportedFields.includes(field.type)) {
|
||||
const schema = field.required && !isPartialSchema ? nonEmptyString() : stringSchema;
|
||||
if (!schema.safeParse(response).success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Unsupported field.type with variants: ${field.type}`);
|
||||
}
|
||||
}
|
||||
fields.forEach((subField) => {
|
||||
const schema = subField.required && !isPartialSchema ? nonEmptyString() : stringSchema;
|
||||
if (!variantSupportedFields.includes(subField.type)) {
|
||||
throw new Error(`Unsupported field.type with variants: ${subField.type}`);
|
||||
}
|
||||
const valueIdentified = response as unknown as Record<string, string>;
|
||||
if (subField.required) {
|
||||
if (!schema.safeParse(valueIdentified[subField.name]).success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
|
||||
return;
|
||||
}
|
||||
if (!isPartialSchema && !valueIdentified[subField.name])
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* DB Read schema has no field type based validation because user might change the type of a field from Type1 to Type2 after some data has been collected with Type1.
|
||||
* Parsing that type1 data with type2 schema will fail.
|
||||
* So, we just validate that the response conforms to one of the field types' schema.
|
||||
*/
|
||||
export const dbReadResponseSchema = z.union([
|
||||
z.string(),
|
||||
z.boolean(),
|
||||
z.string().array(),
|
||||
z.object({
|
||||
optionValue: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
// For variantsConfig case
|
||||
z.record(z.string()),
|
||||
]);
|
||||
50
calcom/packages/features/form-builder/utils.ts
Normal file
50
calcom/packages/features/form-builder/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const preprocessNameFieldDataWithVariant = (
|
||||
variantName: "fullName" | "firstAndLastName",
|
||||
value: string | Record<"firstName" | "lastName", string> | undefined
|
||||
) => {
|
||||
// We expect an object here, but if we get a string, then we will try to transform it into the appropriate object
|
||||
if (variantName === "firstAndLastName") {
|
||||
return getFirstAndLastName(value);
|
||||
// We expect a string here, but if we get an object, then we will try to transform it into the appropriate string
|
||||
} else {
|
||||
return getFullName(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const getFullName = (name: string | { firstName: string; lastName?: string } | undefined) => {
|
||||
if (!name) {
|
||||
return "";
|
||||
}
|
||||
let nameString = "";
|
||||
if (typeof name === "string") {
|
||||
nameString = name;
|
||||
} else {
|
||||
nameString = name.firstName;
|
||||
if (name.lastName) {
|
||||
nameString = `${nameString} ${name.lastName}`;
|
||||
}
|
||||
}
|
||||
return nameString;
|
||||
};
|
||||
|
||||
function getFirstAndLastName(value: string | Record<"firstName" | "lastName", string> | undefined) {
|
||||
let newValue: Record<"firstName" | "lastName", string>;
|
||||
value = value || "";
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
// Support name={"firstName": "John", "lastName": "Johny Janardan"} for prefilling
|
||||
newValue = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Support name="John Johny Janardan" to be filled as firstName="John" and lastName="Johny Janardan"
|
||||
const parts = value.split(" ").map((part) => part.trim());
|
||||
const firstName = parts[0];
|
||||
const lastName = parts.slice(1).join(" ");
|
||||
|
||||
// If the value is not a valid JSON, then we will just use the value as is as it can be the full name directly
|
||||
newValue = { firstName, lastName };
|
||||
}
|
||||
} else {
|
||||
newValue = value;
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getValidRhfFieldName } from "@calcom/lib/getValidRhfFieldName";
|
||||
|
||||
export const getFieldIdentifier = (name: string) => {
|
||||
return getValidRhfFieldName(name);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type z from "zod";
|
||||
|
||||
import type { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { fieldTypesConfigMap } from "../fieldTypes";
|
||||
import type { fieldSchema } from "../schema";
|
||||
|
||||
type ConfigVariants = NonNullable<ReturnType<typeof getConfig>>["variants"];
|
||||
type Field = z.infer<typeof fieldSchema>;
|
||||
type Translate = ReturnType<typeof useLocale>["t"];
|
||||
|
||||
function getTranslatedConfigVariants(configVariants: ConfigVariants, translate: Translate) {
|
||||
return Object.entries(configVariants).reduce((variantsConfigVariants, [variantName, variant]) => {
|
||||
const translatedFields = variant.fields.map((field) => {
|
||||
const label = field.label ?? "";
|
||||
const placeholder = field.placeholder ?? "";
|
||||
return {
|
||||
...field,
|
||||
label: translate(label),
|
||||
placeholder: translate(placeholder),
|
||||
};
|
||||
});
|
||||
variantsConfigVariants[variantName] = {
|
||||
...variant,
|
||||
fields: translatedFields,
|
||||
};
|
||||
|
||||
return variantsConfigVariants;
|
||||
}, {} as typeof configVariants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get's the field's variantsConfig and if not available, then it will get the default variantsConfig from the fieldTypesConfigMap
|
||||
*/
|
||||
export const getConfig = (field: Pick<Field, "variantsConfig" | "type">) => {
|
||||
const fieldVariantsConfig = field.variantsConfig;
|
||||
const fieldTypeConfig = fieldTypesConfigMap[field.type as keyof typeof fieldTypesConfigMap];
|
||||
|
||||
if (!fieldTypeConfig) throw new Error(`Invalid field.type ${field.type}}`);
|
||||
|
||||
const defaultVariantsConfig = fieldTypeConfig?.variantsConfig?.defaultValue;
|
||||
const variantsConfig = fieldVariantsConfig || defaultVariantsConfig;
|
||||
|
||||
if (fieldTypeConfig.propsType === "variants" && !variantsConfig) {
|
||||
throw new Error(`propsType variants must have variantsConfig`);
|
||||
}
|
||||
return variantsConfig;
|
||||
};
|
||||
|
||||
export const getTranslatedConfig = (field: Pick<Field, "variantsConfig" | "type">, translate: Translate) => {
|
||||
const variantsConfig = getConfig(field);
|
||||
if (!variantsConfig) return variantsConfig;
|
||||
const newVariantsConfigVariants = getTranslatedConfigVariants(variantsConfig.variants, translate);
|
||||
|
||||
return {
|
||||
...variantsConfig,
|
||||
variants: newVariantsConfigVariants,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user