Feature/ Manage Booking Questions (#6560)
* WIP * Create Booking Questions builder * Renaming things * wip * wip * Implement Add Guests and other fixes * Fixes after testing * Fix wrong status code 404 * Fixes * Lint fixes * Self review comments addressed * More self review comments addressed * Feedback from zomars * BugFixes after testing * More fixes discovered during review * Update packages/lib/hooks/useHasPaidPlan.ts Co-authored-by: Omar López <zomars@me.com> * More fixes discovered during review * Update packages/ui/components/form/inputs/Input.tsx Co-authored-by: Omar López <zomars@me.com> * More fixes discovered during review * Update packages/features/bookings/lib/getBookingFields.ts Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * More PR review fixes * Hide label using labelSrOnly * Fix Carinas feedback and implement 2 workflows thingy * Misc fixes * Fixes from Loom comments and PR * Fix a lint errr * Fix cancellation reason * Fix regression in edit due to name conflict check * Update packages/features/form-builder/FormBuilder.tsx Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * Fix options not set when default value is used * Restoring reqBody to avoid uneeded conflicts with main * Type fix * Update apps/web/components/booking/pages/BookingPage.tsx Co-authored-by: Omar López <zomars@me.com> * Update packages/features/form-builder/FormBuilder.tsx Co-authored-by: Omar López <zomars@me.com> * Update apps/web/components/booking/pages/BookingPage.tsx Co-authored-by: Omar López <zomars@me.com> * Apply suggestions from code review Co-authored-by: Omar López <zomars@me.com> * Show fields but mark them disabled * Apply suggestions from code review Co-authored-by: Omar López <zomars@me.com> * More comments * Fix booking success page crash when a booking doesnt have newly added required fields response * Dark theme asterisk not visible * Make location required in zodSchema as was there in production * Linting * Remove _metadata.ts files for apps that have config.json * Revert "Remove _metadata.ts files for apps that have config.json" This reverts commit d79bdd336cf312a30a8943af94c059947bd91ccd. * Fix lint error * Fix missing condition for samlSPConfig * Delete unexpectedly added file * yarn.lock change not required * fix types * Make checkboxes rounded * Fix defaultLabel being stored as label due to SSR rendering * Shaved 16kb from booking page * Explicit types for profile * Show payment value only if price is greater than 0 * Fix type error * Add back inferred types as they are failing * Fix duplicate label on number --------- Co-authored-by: zomars <zomars@me.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Efraín Rochín <roae.85@gmail.com>
This commit is contained in:
co-authored by
Omar López
sean-brydon
Carina Wollendorfer
Efraín Rochín
parent
51bf613621
commit
517cfde5b8
@@ -0,0 +1,384 @@
|
||||
import { useEffect } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type {
|
||||
TextLikeComponentProps,
|
||||
SelectLikeComponentProps,
|
||||
} from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets";
|
||||
import Widgets from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingFieldType } from "@calcom/prisma/zod-utils";
|
||||
import { PhoneInput, AddressInput, Button, Label, Group, RadioField, EmailField, Tooltip } from "@calcom/ui";
|
||||
import { FiUserPlus, FiX } from "@calcom/ui/components/icon";
|
||||
|
||||
import { ComponentForField } from "./FormBuilder";
|
||||
import type { fieldsSchema } from "./FormBuilderFieldsSchema";
|
||||
|
||||
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 fieldsSchema>[number]["optionsInputs"]>;
|
||||
value: { value: string; optionValue: string };
|
||||
} & {
|
||||
name?: string;
|
||||
}
|
||||
>(
|
||||
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<BookingFieldType, Component> = {
|
||||
text: {
|
||||
propsType: "text",
|
||||
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
|
||||
},
|
||||
textarea: {
|
||||
propsType: "text",
|
||||
// TODO: Make rows configurable in the form builder
|
||||
factory: (props) => <Widgets.TextAreaWidget rows={3} {...props} />,
|
||||
},
|
||||
number: {
|
||||
propsType: "text",
|
||||
factory: (props) => <Widgets.NumberWidget noLabel={true} {...props} />,
|
||||
},
|
||||
name: {
|
||||
propsType: "text",
|
||||
// Keep special "name" type field and later build split(FirstName and LastName) variant of it.
|
||||
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
|
||||
},
|
||||
phone: {
|
||||
propsType: "text",
|
||||
factory: ({ setValue, readOnly, ...props }) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PhoneInput
|
||||
disabled={readOnly}
|
||||
onChange={(val: string) => {
|
||||
setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
email: {
|
||||
propsType: "text",
|
||||
factory: (props) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
return <Widgets.TextWidget type="email" noLabel={true} {...props} />;
|
||||
},
|
||||
},
|
||||
address: {
|
||||
propsType: "text",
|
||||
factory: (props) => {
|
||||
return (
|
||||
<AddressInput
|
||||
onChange={(val) => {
|
||||
props.setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
multiemail: {
|
||||
propsType: "textList",
|
||||
//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-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500";
|
||||
return (
|
||||
<>
|
||||
{value.length ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="guests"
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
<ul>
|
||||
{value.map((field, index) => (
|
||||
<li key={index}>
|
||||
<EmailField
|
||||
disabled={readOnly}
|
||||
value={value[index]}
|
||||
onChange={(e) => {
|
||||
value[index] = e.target.value;
|
||||
setValue(value);
|
||||
}}
|
||||
className={classNames(inputClassName, "border-r-0")}
|
||||
addOnClassname={classNames(
|
||||
"border-gray-300 border block border-l-0 disabled:bg-gray-200 disabled:hover:cursor-not-allowed bg-transparent disabled:text-gray-500 dark:border-darkgray-300 "
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
label={<></>}
|
||||
required
|
||||
addOnSuffix={
|
||||
!readOnly ? (
|
||||
<Tooltip content="Remove email">
|
||||
<button
|
||||
className="m-1 disabled:hover:cursor-not-allowed"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
value.splice(index, 1);
|
||||
setValue(value);
|
||||
}}>
|
||||
<FiX className="text-gray-600" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
StartIcon={FiUserPlus}
|
||||
className="my-2.5"
|
||||
onClick={() => {
|
||||
value.push("");
|
||||
setValue(value);
|
||||
}}>
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{!value.length && !readOnly && (
|
||||
<Button
|
||||
color="minimal"
|
||||
variant="button"
|
||||
StartIcon={FiUserPlus}
|
||||
onClick={() => {
|
||||
value.push("");
|
||||
setValue(value);
|
||||
}}
|
||||
className="mr-auto">
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
multiselect: {
|
||||
propsType: "multiselect",
|
||||
factory: (props) => {
|
||||
const newProps = {
|
||||
...props,
|
||||
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
|
||||
};
|
||||
return <Widgets.MultiSelectWidget {...newProps} />;
|
||||
},
|
||||
},
|
||||
select: {
|
||||
propsType: "select",
|
||||
factory: (props) => {
|
||||
const newProps = {
|
||||
...props,
|
||||
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
|
||||
};
|
||||
return <Widgets.SelectWidget {...newProps} />;
|
||||
},
|
||||
},
|
||||
checkbox: {
|
||||
propsType: "multiselect",
|
||||
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="dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 rounded border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
value={option.value}
|
||||
checked={value.includes(option.value)}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||
{option.label ?? ""}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
radio: {
|
||||
propsType: "select",
|
||||
factory: ({ setValue, 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={`option.${i}.radio`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
radioInput: {
|
||||
propsType: "objectiveWithInput",
|
||||
factory: function RadioInputWithLabel({ name, options, optionsInputs, value, setValue, readOnly }) {
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setValue({
|
||||
value: options[0]?.value,
|
||||
optionValue: "",
|
||||
});
|
||||
}
|
||||
}, [options, setValue, value]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{options.length > 1 ? (
|
||||
options.map((option, i) => {
|
||||
return (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
className="dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
setValue({
|
||||
value: e.target.value,
|
||||
optionValue: "",
|
||||
});
|
||||
}}
|
||||
checked={value?.value === option.value}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||
{option.label ?? ""}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Show option itself as label because there is just one option
|
||||
// TODO: Support asterisk for required fields
|
||||
<Label>{options[0].label}</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) => {
|
||||
setValue({
|
||||
value: value?.value,
|
||||
optionValue: val,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
boolean: {
|
||||
propsType: "boolean",
|
||||
factory: ({ readOnly, label, value, setValue }) => {
|
||||
return (
|
||||
<div className="flex">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setValue(true);
|
||||
} else {
|
||||
setValue(false);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
checked={value}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Label className="-mt-px block text-sm font-medium text-gray-700 dark:text-white">{label}</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
|
||||
@@ -0,0 +1,733 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Label,
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
Form,
|
||||
BooleanToggleGroupField,
|
||||
SelectField,
|
||||
InputField,
|
||||
Input,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Switch } from "@calcom/ui";
|
||||
import { FiArrowDown, FiArrowUp, FiX, FiPlus, FiTrash2, FiInfo } from "@calcom/ui/components/icon";
|
||||
|
||||
import { Components } from "./Components";
|
||||
import type { fieldsSchema } from "./FormBuilderFieldsSchema";
|
||||
|
||||
type RhfForm = {
|
||||
fields: z.infer<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,
|
||||
}: {
|
||||
formProp: string;
|
||||
title: string;
|
||||
description: string;
|
||||
addFieldLabel: string;
|
||||
}) {
|
||||
const FieldTypesMap: Record<
|
||||
string,
|
||||
{
|
||||
value: RhfForm["fields"][number]["type"];
|
||||
label: string;
|
||||
needsOptions?: boolean;
|
||||
systemOnly?: boolean;
|
||||
isTextType?: boolean;
|
||||
}
|
||||
> = {
|
||||
name: {
|
||||
label: "Name",
|
||||
value: "name",
|
||||
isTextType: true,
|
||||
},
|
||||
email: {
|
||||
label: "Email",
|
||||
value: "email",
|
||||
isTextType: true,
|
||||
},
|
||||
phone: {
|
||||
label: "Phone",
|
||||
value: "phone",
|
||||
isTextType: true,
|
||||
},
|
||||
text: {
|
||||
label: "Short Text",
|
||||
value: "text",
|
||||
isTextType: true,
|
||||
},
|
||||
number: {
|
||||
label: "Number",
|
||||
value: "number",
|
||||
isTextType: true,
|
||||
},
|
||||
textarea: {
|
||||
label: "Long Text",
|
||||
value: "textarea",
|
||||
isTextType: true,
|
||||
},
|
||||
select: {
|
||||
label: "Select",
|
||||
value: "select",
|
||||
needsOptions: true,
|
||||
isTextType: true,
|
||||
},
|
||||
multiselect: {
|
||||
label: "MultiSelect",
|
||||
value: "multiselect",
|
||||
needsOptions: true,
|
||||
isTextType: false,
|
||||
},
|
||||
multiemail: {
|
||||
label: "Multiple Emails",
|
||||
value: "multiemail",
|
||||
isTextType: true,
|
||||
},
|
||||
radioInput: {
|
||||
label: "Radio Input",
|
||||
value: "radioInput",
|
||||
isTextType: false,
|
||||
systemOnly: true,
|
||||
},
|
||||
checkbox: {
|
||||
label: "Checkbox Group",
|
||||
value: "checkbox",
|
||||
needsOptions: true,
|
||||
isTextType: false,
|
||||
},
|
||||
radio: {
|
||||
label: "Radio Group",
|
||||
value: "radio",
|
||||
needsOptions: true,
|
||||
isTextType: false,
|
||||
},
|
||||
boolean: {
|
||||
label: "Checkbox",
|
||||
value: "boolean",
|
||||
isTextType: false,
|
||||
},
|
||||
};
|
||||
const FieldTypes = Object.values(FieldTypesMap);
|
||||
|
||||
// I would have liked to give Form Builder it's own Form but nested Forms aren't something that browsers support.
|
||||
// So, this would reuse the same Form as the parent form.
|
||||
const fieldsForm = useFormContext<RhfForm>();
|
||||
|
||||
const { t } = useLocale();
|
||||
const fieldForm = useForm<RhfFormField>();
|
||||
const { fields, swap, remove, update, append } = useFieldArray({
|
||||
control: fieldsForm.control,
|
||||
// HACK: It allows any property name to be used for instead of `fields` property name
|
||||
name: formProp as unknown as "fields",
|
||||
});
|
||||
|
||||
function OptionsField({
|
||||
label = "Options",
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
readOnly = false,
|
||||
}: {
|
||||
label?: string;
|
||||
value: { label: string; value: string }[];
|
||||
onChange: (value: { label: string; value: string }[]) => void;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [animationRef] = useAutoAnimate<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="rounded-md bg-gray-50 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.toLowerCase().trim(),
|
||||
});
|
||||
onChange(value);
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
placeholder={`Enter Option ${index + 1}`}
|
||||
/>
|
||||
{value.length > 2 && !readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
className="mb-2 -ml-8 hover:!bg-transparent focus:!bg-transparent focus:!outline-none focus:!ring-0"
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon={FiX}
|
||||
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={FiPlus}>
|
||||
Add an Option
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [fieldDialog, setFieldDialog] = useState({
|
||||
isOpen: false,
|
||||
fieldIndex: -1,
|
||||
});
|
||||
|
||||
const addField = () => {
|
||||
fieldForm.reset({});
|
||||
setFieldDialog({
|
||||
isOpen: true,
|
||||
fieldIndex: -1,
|
||||
});
|
||||
};
|
||||
|
||||
const editField = (index: number, data: RhfFormField) => {
|
||||
fieldForm.reset(data);
|
||||
setFieldDialog({
|
||||
isOpen: true,
|
||||
fieldIndex: index,
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const fieldType = FieldTypesMap[fieldForm.watch("type")];
|
||||
const isFieldEditMode = fieldDialog.fieldIndex !== -1;
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 ltr:mr-1 rtl:ml-1">{title}</div>
|
||||
<p className="max-w-[280px] break-words py-1 text-sm text-gray-500 sm:max-w-[500px]">{description}</p>
|
||||
<ul className="mt-2 rounded-md border">
|
||||
{fields.map((field, index) => {
|
||||
const fieldType = FieldTypesMap[field.type];
|
||||
|
||||
// Hidden fields can't be required
|
||||
const isRequired = field.required && !field.hidden;
|
||||
|
||||
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={index}
|
||||
className="group relative flex items-center justify-between border-b p-4 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
className="invisible absolute -left-[12px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
|
||||
onClick={() => swap(index, index - 1)}>
|
||||
<FiArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="invisible absolute -left-[12px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
|
||||
onClick={() => swap(index, index + 1)}>
|
||||
<FiArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center">
|
||||
<div className="text-sm font-semibold text-gray-700 ltr:mr-1 rtl:ml-1">
|
||||
{field.label || t(field.defaultLabel || "")}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="gray">{isRequired ? "Required" : "Optional"}</Badge>
|
||||
{field.hidden ? <Badge variant="gray">Hidden</Badge> : null}
|
||||
{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="max-w-[280px] break-words py-1 text-sm text-gray-500 sm:max-w-[500px]">
|
||||
{fieldType.label}
|
||||
</p>
|
||||
</div>
|
||||
{field.editable !== "user-readonly" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
disabled={field.editable === "system"}
|
||||
tooltip={field.editable === "system" ? t("form_builder_system_field_cant_toggle") : ""}
|
||||
checked={!field.hidden}
|
||||
onCheckedChange={(checked) => {
|
||||
update(index, { ...field, hidden: !checked });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
editField(index, field);
|
||||
}}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
color="minimal"
|
||||
tooltip={
|
||||
field.editable === "system" || field.editable === "system-but-optional"
|
||||
? t("form_builder_system_field_cant_delete")
|
||||
: ""
|
||||
}
|
||||
disabled={field.editable === "system" || field.editable === "system-but-optional"}
|
||||
variant="icon"
|
||||
onClick={() => {
|
||||
removeField(index);
|
||||
}}
|
||||
StartIcon={FiTrash2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Button color="minimal" onClick={addField} className="mt-4" StartIcon={FiPlus}>
|
||||
{addFieldLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={fieldDialog.isOpen}
|
||||
onOpenChange={(isOpen) =>
|
||||
setFieldDialog({
|
||||
isOpen,
|
||||
fieldIndex: -1,
|
||||
})
|
||||
}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("add_a_booking_question")} subtitle={t("form_builder_field_add_subtitle")} />
|
||||
<div>
|
||||
<Form
|
||||
form={fieldForm}
|
||||
handleSubmit={(data) => {
|
||||
const isNewField = fieldDialog.fieldIndex == -1;
|
||||
if (isNewField && fields.some((f) => f.name === data.name)) {
|
||||
showToast(t("form_builder_field_already_exists"), "error");
|
||||
return;
|
||||
}
|
||||
if (fieldDialog.fieldIndex !== -1) {
|
||||
update(fieldDialog.fieldIndex, data);
|
||||
} else {
|
||||
const field: RhfFormField = {
|
||||
...data,
|
||||
sources: [
|
||||
{
|
||||
label: "User",
|
||||
type: "user",
|
||||
id: "user",
|
||||
fieldRequired: data.required,
|
||||
},
|
||||
],
|
||||
};
|
||||
field.editable = field.editable || "user";
|
||||
append(field);
|
||||
}
|
||||
setFieldDialog({
|
||||
isOpen: false,
|
||||
fieldIndex: -1,
|
||||
});
|
||||
}}>
|
||||
<SelectField
|
||||
required
|
||||
isDisabled={
|
||||
fieldForm.getValues("editable") === "system" ||
|
||||
fieldForm.getValues("editable") === "system-but-optional"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
fieldForm.setValue("type", value);
|
||||
}}
|
||||
value={FieldTypesMap[fieldForm.getValues("type")]}
|
||||
options={FieldTypes.filter((f) => !f.systemOnly)}
|
||||
label="Input Type"
|
||||
/>
|
||||
<InputField
|
||||
required
|
||||
{...fieldForm.register("name")}
|
||||
containerClassName="mt-6"
|
||||
disabled={
|
||||
fieldForm.getValues("editable") === "system" ||
|
||||
fieldForm.getValues("editable") === "system-but-optional"
|
||||
}
|
||||
label="Name"
|
||||
/>
|
||||
<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="Label"
|
||||
/>
|
||||
{fieldType?.isTextType ? (
|
||||
<InputField
|
||||
{...fieldForm.register("placeholder")}
|
||||
containerClassName="mt-6"
|
||||
label="Placeholder"
|
||||
placeholder={t(fieldForm.getValues("defaultPlaceholder") || "")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{fieldType?.needsOptions ? (
|
||||
<Controller
|
||||
name="options"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <OptionsField onChange={onChange} value={value} className="mt-6" />;
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Controller
|
||||
name="required"
|
||||
control={fieldForm.control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<BooleanToggleGroupField
|
||||
disabled={fieldForm.getValues("editable") === "system"}
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label="Required"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose color="secondary">Cancel</DialogClose>
|
||||
<Button type="submit">{isFieldEditMode ? t("save") : t("add")}</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
}) => {
|
||||
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 items-center">{field.label}</Label>
|
||||
<span className="ml-1 -mb-1 text-sm font-medium leading-none dark:text-white">
|
||||
{!readOnly && field.required ? "*" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ValueProps =
|
||||
| {
|
||||
value: string[];
|
||||
setValue: (value: string[]) => void;
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}
|
||||
| {
|
||||
value: {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
};
|
||||
setValue: (value: { value: string; optionValue: string }) => void;
|
||||
}
|
||||
| {
|
||||
value: boolean;
|
||||
setValue: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const ComponentForField = ({
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
readOnly,
|
||||
}: {
|
||||
field: Omit<RhfFormField, "editable" | "label"> & {
|
||||
// Label is optional because radioInput doesn't have a label
|
||||
label?: string;
|
||||
};
|
||||
readOnly: boolean;
|
||||
} & ValueProps) => {
|
||||
const fieldType = field.type;
|
||||
const componentConfig = Components[fieldType];
|
||||
|
||||
const isValueOfPropsType = (val: unknown, propsType: typeof componentConfig.propsType) => {
|
||||
const propsTypeConditionMap = {
|
||||
boolean: typeof val === "boolean",
|
||||
multiselect: val instanceof Array && val.every((v) => typeof v === "string"),
|
||||
objectiveWithInput: typeof val === "object" && val !== null ? "value" in val : false,
|
||||
select: typeof val === "string",
|
||||
text: typeof val === "string",
|
||||
textList: val instanceof Array && val.every((v) => typeof v === "string"),
|
||||
} as const;
|
||||
if (!propsTypeConditionMap[propsType]) throw new Error(`Unknown propsType ${propsType}`);
|
||||
return propsTypeConditionMap[propsType];
|
||||
};
|
||||
|
||||
// If possible would have wanted `isValueOfPropsType` to narrow the type of `value` and `setValue` accordingly, but can't seem to do it.
|
||||
// So, code following this uses type assertion to tell TypeScript that everything has been validated
|
||||
if (value !== undefined && !isValueOfPropsType(value, componentConfig.propsType)) {
|
||||
throw new Error(
|
||||
`Value ${value} is not valid for type ${componentConfig.propsType} for field ${field.name}`
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "text") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
name={field.name}
|
||||
value={value as string}
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
/>
|
||||
</WithLabel>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "boolean") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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");
|
||||
}
|
||||
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={field.options}
|
||||
/>
|
||||
</WithLabel>
|
||||
) : null;
|
||||
}
|
||||
|
||||
throw new Error(`Field ${field.name} does not have a valid propsType`);
|
||||
};
|
||||
|
||||
export const FormBuilderField = ({
|
||||
field,
|
||||
readOnly,
|
||||
className,
|
||||
}: {
|
||||
field: RhfFormFields[number];
|
||||
readOnly: boolean;
|
||||
className: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, formState } = useFormContext();
|
||||
return (
|
||||
<div
|
||||
data-form-builder-field-name={field.name}
|
||||
className={classNames(className, field.hidden ? "hidden" : "")}>
|
||||
<Controller
|
||||
control={control}
|
||||
// Make it a variable
|
||||
name={`responses.${field.name}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
field={field}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
setValue={(val: unknown) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<ErrorMessage
|
||||
name="responses"
|
||||
errors={formState.errors}
|
||||
render={({ message }) => {
|
||||
const name = message?.replace(/\{([^}]+)\}.*/, "$1");
|
||||
// Use the message targeted for it.
|
||||
if (name !== field.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
message = message.replace(/\{[^}]+\}(.*)/, "$1").trim();
|
||||
if (field.hidden) {
|
||||
console.error(`Error message for hidden field:${field.name} => ${message}`);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-field-name={field.name}
|
||||
className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t(message)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const fieldTypeEnum = z.enum([
|
||||
"name",
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"multiemail",
|
||||
"select",
|
||||
"multiselect",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"radioInput",
|
||||
"boolean",
|
||||
]);
|
||||
|
||||
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
|
||||
"user", // Fully editable
|
||||
"user-readonly", // All fields are readOnly.
|
||||
]);
|
||||
|
||||
const fieldSchema = z.object({
|
||||
name: z.string(),
|
||||
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Supports translation
|
||||
*/
|
||||
defaultLabel: z.string().optional(),
|
||||
defaultPlaceholder: z.string().optional(),
|
||||
|
||||
type: fieldTypeEnum,
|
||||
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
||||
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(),
|
||||
|
||||
required: z.boolean().default(false).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
editable: 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
|
||||
"user", // Fully editable
|
||||
"user-readonly", // All fields are readOnly.
|
||||
])
|
||||
.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);
|
||||
@@ -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 "./FormBuilder";
|
||||
Reference in New Issue
Block a user