fix: Update forms UI (#8093)

* Routing Forms: Update forms UI

* fix failing test

* fix: Routing Forms forms UI Update

* fix: Routing Forms forms UI Update

* Apply suggestions from code review

---------

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
GitStart-Cal.com
2023-06-05 05:51:19 +00:00
committed by GitHub
co-authored by gitstart-calcom Peer Richelsen Omar López Hariom Balhara
parent 9d91d72a72
commit 175f2482e1
12 changed files with 223 additions and 92 deletions
@@ -1713,6 +1713,7 @@
"spot_popular_event_types": "Spot popular event types",
"spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings",
"no_responses_yet": "No responses yet",
"no_routes_defined": "No routes defined",
"this_will_be_the_placeholder": "This will be the placeholder",
"error_booking_event": "An error occured when booking the event, please refresh the page and try again",
"timeslot_missing_title": "No timeslot selected",
@@ -34,10 +34,18 @@ import {
Tooltip,
VerticalDivider,
} from "@calcom/ui";
import { ExternalLink, Link as LinkIcon, Download, Code, Trash } from "@calcom/ui/components/icon";
import {
ExternalLink,
Link as LinkIcon,
Download,
Code,
Trash,
MessageCircle,
} from "@calcom/ui/components/icon";
import { RoutingPages } from "../lib/RoutingPages";
import { getSerializableForm } from "../lib/getSerializableForm";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { processRoute } from "../lib/processRoute";
import type { Response, Route, SerializableForm } from "../types/types";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
@@ -371,18 +379,26 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
{t("test_preview")}
</Button>
</div>
{form.routes?.every(isFallbackRoute) && (
<Alert
className="mt-6 !bg-orange-100 font-semibold text-orange-900"
iconClassName="!text-orange-900"
severity="neutral"
title={t("no_routes_defined")}
/>
)}
{!form._count?.responses && (
<>
<Alert
className="mt-6"
className="mt-2 px-4 py-3"
severity="neutral"
title={t("no_responses_yet")}
message={t("responses_collection_waiting_description")}
CustomIcon={MessageCircle}
/>
</>
)}
</div>
<div className="border-subtle w-full rounded-md border p-8">
<div className="border-subtle bg-muted w-full rounded-md border p-8">
<RoutingNavBar appUrl={appUrl} form={form} />
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
@@ -12,11 +12,12 @@ import {
Button,
EmptyScreen,
FormCard,
Label,
SelectField,
TextAreaField,
Skeleton,
TextField,
} from "@calcom/ui";
import { Plus, FileText } from "@calcom/ui/components/icon";
import { Plus, FileText, X } from "@calcom/ui/components/icon";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -27,6 +28,7 @@ import SingleForm, {
export { getServerSideProps };
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
type SelectOption = { placeholder: string; value: string };
export const FieldTypes = [
{
@@ -42,11 +44,11 @@ export const FieldTypes = [
value: "textarea",
},
{
label: "Select",
label: "Single Selection",
value: "select",
},
{
label: "MultiSelect",
label: "Multiple Selection",
value: "multiselect",
},
{
@@ -85,30 +87,70 @@ function Field({
};
appUrl: string;
}) {
const [identifier, _setIdentifier] = useState(hookForm.getValues(`${hookFieldNamespace}.identifier`));
const { t } = useLocale();
const setUserChangedIdentifier = (val: string) => {
_setIdentifier(val);
// Also, update the form identifier so tha it can be persisted
hookForm.setValue(`${hookFieldNamespace}.identifier`, val);
const [options, setOptions] = useState<SelectOption[]>([
{ placeholder: "< 10", value: "" },
{ placeholder: "10-100", value: "" },
{ placeholder: "100-500", value: "" },
{ placeholder: "> 500", value: "" },
]);
const handleRemoveOptions = (index: number) => {
const updatedOptions = options.filter((_, i) => i !== index);
setOptions(updatedOptions);
updateSelectText(updatedOptions);
};
const label = hookForm.watch(`${hookFieldNamespace}.label`);
const handleAddOptions = () => {
setOptions((prevState) => [
...prevState,
{
placeholder: "New Option",
value: "",
},
]);
};
useEffect(() => {
if (!hookForm.getValues(`${hookFieldNamespace}.identifier`)) {
_setIdentifier(label);
const originalValues = hookForm.getValues(`${hookFieldNamespace}.selectText`);
if (originalValues) {
const values: SelectOption[] = originalValues.split("\n").map((fieldValue) => ({
value: fieldValue,
placeholder: "",
}));
setOptions(values);
}
}, [label, hookFieldNamespace, hookForm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, optionIndex: number) => {
const updatedOptions = options.map((opt, index) => ({
...opt,
...(index === optionIndex ? { value: e.target.value } : {}),
}));
setOptions(updatedOptions);
updateSelectText(updatedOptions);
};
const updateSelectText = (updatedOptions: SelectOption[]) => {
hookForm.setValue(
`${hookFieldNamespace}.selectText`,
updatedOptions
.filter((opt) => opt.value)
.map((opt) => opt.value)
.join("\n")
);
};
return (
<div
data-testid="field"
className="group mb-4 flex w-full items-center justify-between ltr:mr-2 rtl:ml-2">
className="bg-default group mb-4 flex w-full items-center justify-between ltr:mr-2 rtl:ml-2">
<FormCard
label={label || `Field ${fieldIndex + 1}`}
label="Field"
moveUp={moveUp}
moveDown={moveDown}
badge={
@@ -120,6 +162,7 @@ function Field({
<TextField
disabled={!!router}
label="Label"
className="flex-grow"
placeholder={t("this_is_what_your_users_would_see")}
/**
* This is a bit of a hack to make sure that for routerField, label is shown from there.
@@ -137,18 +180,16 @@ function Field({
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
value={identifier}
defaultValue={routerField?.identifier || routerField?.label}
onChange={(e) => setUserChangedIdentifier(e.target.value)}
/>
</div>
<div className="mb-6 w-full">
<TextField
disabled={!!router}
label={t("placeholder")}
placeholder={t("this_will_be_the_placeholder")}
defaultValue={routerField?.placeholder}
{...hookForm.register(`${hookFieldNamespace}.placeholder`)}
//This change has the same effects that already existed in relation to this field,
// but written in a different way.
// The identifier field will have the same value as the label field until it is changed
defaultValue={
hookForm.watch(`${hookFieldNamespace}.identifier`) ||
hookForm.watch(`${hookFieldNamespace}.label`)
}
onChange={(e) => {
hookForm.setValue(`${hookFieldNamespace}.identifier`, e.target.value);
}}
/>
</div>
<div className="mb-6 w-full ">
@@ -160,6 +201,17 @@ function Field({
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
return (
<SelectField
maxMenuHeight={200}
styles={{
singleValue: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
option: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
}}
label="Type"
isDisabled={!!router}
containerClassName="data-testid-field-type"
@@ -177,21 +229,48 @@ function Field({
/>
</div>
{["select", "multiselect"].includes(hookForm.watch(`${hookFieldNamespace}.type`)) ? (
<div className="mt-2 block items-center sm:flex">
<div className="w-full">
<TextAreaField
disabled={!!router}
rows={3}
label="Options"
defaultValue={routerField?.selectText}
placeholder={t("add_1_option_per_line")}
{...hookForm.register(`${hookFieldNamespace}.selectText`)}
/>
<div className="mt-2 w-full">
<Skeleton as={Label} loadingClassName="w-16" title={t("Options")}>
{t("options")}
</Skeleton>
{options.map((field, index) => (
<div key={`select-option-${index}`}>
<TextField
disabled={!!router}
containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400"
className="border-0 focus:ring-0 focus:ring-offset-0"
labelSrOnly
placeholder={field.placeholder.toString()}
value={field.value}
type="text"
addOnClassname="bg-transparent border-0"
onChange={(e) => handleChange(e, index)}
addOnSuffix={
<button
type="button"
onClick={() => handleRemoveOptions(index)}
aria-label={t("remove")}>
<X className="h-4 w-4" />
</button>
}
/>
</div>
))}
<div className={classNames("flex")}>
<Button
data-testid="add-attribute"
className="border-none"
type="button"
StartIcon={Plus}
color="secondary"
onClick={handleAddOptions}>
Add an option
</Button>
</div>
</div>
) : null}
<div className="w-full">
<div className="w-[106px]">
<Controller
name={`${hookFieldNamespace}.required`}
control={hookForm.control}
@@ -199,6 +278,7 @@ function Field({
render={({ field: { value, onChange } }) => {
return (
<BooleanToggleGroupField
variant="small"
disabled={!!router}
label={t("required")}
value={value}
@@ -256,7 +336,7 @@ const FormEdit = ({
return hookFormFields.length ? (
<div className="flex flex-col-reverse lg:flex-row">
<div className="w-full ltr:mr-2 rtl:ml-2">
<div ref={animationRef} className="flex w-full flex-col">
<div ref={animationRef} className="flex w-full flex-col rounded-md">
{hookFormFields.map((field, key) => {
return (
<Field
@@ -298,14 +378,14 @@ const FormEdit = ({
StartIcon={Plus}
color="secondary"
onClick={addField}>
Add Field
Add field
</Button>
</div>
) : null}
</div>
</div>
) : (
<div className="w-full">
<div className="bg-default w-full">
<EmptyScreen
Icon={FileText}
headline="Create your first field"
@@ -165,7 +165,7 @@ const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["fo
);
return (
<div className="flex flex-col-reverse md:flex-row">
<div className="cal-query-builder w-full ltr:mr-2 rtl:ml-2">
<div className="cal-query-builder bg-default w-full ltr:mr-2 rtl:ml-2">
<Query
{...config}
value={query.state.tree}
@@ -420,7 +420,7 @@ const Routes = ({
hookForm.setValue("routes", routesToSave);
return (
<div className="flex flex-col-reverse md:flex-row">
<div className="bg-default border-subtle flex flex-col-reverse rounded-md border p-8 md:flex-row">
<div ref={animationRef} className="w-full ltr:mr-2 rtl:ml-2">
{mainRoutes.map((route, key) => {
return (
@@ -136,7 +136,7 @@ test.describe("Routing Forms", () => {
label: "Test Field",
});
const queryString =
"firstField=456&Test Field Number=456&Test Field Select=456&Test Field MultiSelect=456&Test Field MultiSelect=789&Test Field Phone=456&Test Field Email=456@example.com";
"firstField=456&Test Field Number=456&Test Field Single Selection=456&Test Field Multiple Selection=456&Test Field Multiple Selection=789&Test Field Phone=456&Test Field Email=456@example.com";
await gotoRoutingLink({ page, queryString });
@@ -167,8 +167,8 @@ test.describe("Routing Forms", () => {
// All other params come from prefill URL
expect(url.searchParams.get("Test Field Number")).toBe("456");
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
expect(url.searchParams.get("Test Field Select")).toBe("456");
expect(url.searchParams.getAll("Test Field MultiSelect")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test Field Multiple Selection")).toBe("456");
expect(url.searchParams.getAll("Test Field Multiple Selection")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test Field Phone")).toBe("456");
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
});
@@ -447,7 +447,7 @@ async function addAllTypesOfFieldsAndSaveForm(
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
@@ -507,7 +507,7 @@ export async function addOneFieldAndDescriptionAndSaveForm(
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
@@ -264,7 +264,7 @@ const ProfileView = () => {
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600 break-words"
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
/>
</>
+42 -33
View File
@@ -1,6 +1,7 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { forwardRef } from "react";
import type { IconType } from "react-icons";
import { CheckCircle2, Info, XCircle, AlertTriangle } from "@calcom/ui/components/icon";
@@ -14,9 +15,10 @@ export interface AlertProps {
iconClassName?: string;
// @TODO: Success and info shouldn't exist as per design?
severity: "success" | "warning" | "error" | "info" | "neutral";
CustomIcon?: IconType;
}
export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
const { severity, iconClassName } = props;
const { severity, iconClassName, CustomIcon } = props;
return (
<div
@@ -31,38 +33,45 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
severity === "neutral" && "bg-subtle text-default border-none"
)}>
<div className="relative flex flex-col md:flex-row">
<div className="flex-shrink-0">
{severity === "error" && (
<XCircle
className={classNames("h-5 w-5 fill-red-400 text-white", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "warning" && (
<AlertTriangle
className={classNames("h-5 w-5 fill-yellow-400 text-white", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "info" && (
<Info
className={classNames("h-5 w-5 fill-sky-400 text-white", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "neutral" && (
<Info
className={classNames("text-default h-5 w-5 fill-transparent", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "success" && (
<CheckCircle2
className={classNames("fill-muted h-5 w-5 text-white", iconClassName)}
aria-hidden="true"
/>
)}
</div>
{CustomIcon ? (
<div className="flex-shrink-0">
<CustomIcon aria-hidden="true" className={classNames("text-default h-5 w-5", iconClassName)} />
</div>
) : (
<div className="flex-shrink-0">
{severity === "error" && (
<XCircle
className={classNames("h-5 w-5 fill-red-400 text-white", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "warning" && (
<AlertTriangle
className={classNames("h-5 w-5 fill-yellow-400 text-white", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "info" && (
<Info
className={classNames("h-5 w-5 fill-sky-400 text-white", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "neutral" && (
<Info
className={classNames("text-default h-5 w-5 fill-transparent", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "success" && (
<CheckCircle2
className={classNames("fill-muted h-5 w-5 text-white", iconClassName)}
aria-hidden="true"
/>
)}
</div>
)}
<div className="ml-3 flex-grow">
<h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { classNames } from "@calcom/lib";
import type { BadgeProps } from "../..";
import { Badge } from "../..";
import { Divider } from "../divider";
import { ArrowDown, ArrowUp, Trash } from "../icon";
import { ArrowDown, ArrowUp, Trash2 } from "../icon";
type Action = { check: () => boolean; fn: () => void };
export default function FormCard({
@@ -68,7 +68,7 @@ export default function FormCard({
deleteField?.fn();
}}
color="secondary">
<Trash className="text-muted h-4 w-4" />
<Trash2 className="text-default h-4 w-4" />
</button>
) : null}
</div>
@@ -10,18 +10,38 @@ import { Label } from "../../../components/form/inputs/Label";
const boolean = (yesNo: "yes" | "no") => (yesNo === "yes" ? true : yesNo === "no" ? false : undefined);
const yesNo = (boolean?: boolean) => (boolean === true ? "yes" : boolean === false ? "no" : undefined);
type VariantStyles = {
commonClass?: string;
toggleGroupPrimitiveClass?: string;
};
const getVariantStyles = (variant: string) => {
const variants: Record<string, VariantStyles> = {
default: {
commonClass: "px-4 w-full py-[10px]",
},
small: {
commonClass: "w-[49px] px-3 py-1.5",
toggleGroupPrimitiveClass: "space-x-1",
},
};
return variants[variant];
};
export const BooleanToggleGroup = function BooleanToggleGroup({
defaultValue = true,
value,
disabled = false,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onValueChange = () => {},
variant = "default",
...passThrough
}: {
defaultValue?: boolean;
value?: boolean;
onValueChange?: (value?: boolean) => void;
disabled?: boolean;
variant?: "default" | "small";
}) {
// Maintain a state because it is not necessary that onValueChange the parent component would re-render. Think react-hook-form
// Also maintain a string as boolean isn't accepted as ToggleGroupPrimitive value
@@ -33,9 +53,11 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
return null;
}
const commonClass = classNames(
"w-full inline-flex items-center justify-center rounded py-[10px] px-4 text-sm font-medium leading-4",
getVariantStyles(variant).commonClass,
"inline-flex items-center justify-center rounded text-sm font-medium leading-4",
disabled && "cursor-not-allowed"
);
const selectedClass = classNames(commonClass, "bg-emphasis text-emphasis");
const unselectedClass = classNames(commonClass, "text-default hover:bg-subtle hover:text-emphasis");
return (
@@ -43,7 +65,10 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
value={yesNoValue}
type="single"
disabled={disabled}
className="border-subtle flex h-9 space-x-2 rounded-md border p-1 rtl:space-x-reverse"
className={classNames(
"border-subtle flex h-9 space-x-2 rounded-md border p-1 rtl:space-x-reverse",
getVariantStyles(variant).toggleGroupPrimitiveClass
)}
onValueChange={(yesNoValue: "yes" | "no") => {
setYesNoValue(yesNoValue);
onValueChange(boolean(yesNoValue));
@@ -31,7 +31,7 @@ const HorizontalTabItem = function ({ name, href, linkProps, avatar, ...props }:
href={href}
{...linkProps}
className={classNames(
isCurrent ? "bg-subtle text-emphasis" : " hover:bg-subtle hover:text-emphasis text-default ",
isCurrent ? "bg-emphasis text-emphasis" : "hover:bg-subtle hover:text-emphasis text-default",
"inline-flex items-center justify-center whitespace-nowrap rounded-[6px] p-2 text-sm font-medium leading-4 md:mb-0",
props.disabled && "pointer-events-none !opacity-30",
props.className
@@ -15,7 +15,7 @@ const HorizontalTabs = function ({ tabs, linkProps, actions, ...props }: NavTabP
aria-label="Tabs"
{...props}>
{tabs.map((tab, idx) => (
<HorizontalTabItem {...tab} key={idx} {...linkProps} />
<HorizontalTabItem className="py-2.5 px-4" {...tab} key={idx} {...linkProps} />
))}
</nav>
{actions && actions}