Feature/new design radio area group (#6289)

* Refactor of the RadioAreaGroup component

* Move RadioAreaGroup outside of v2/core

* Use useTypedQuery

* Removed unused (broken) imports

* Fix type for Select
This commit is contained in:
Alex van Andel
2023-01-06 20:05:23 +00:00
committed by GitHub
parent 979686920a
commit e5b6a3332e
13 changed files with 260 additions and 412 deletions
@@ -1,79 +0,0 @@
import React, { ReactNode, useState } from "react";
import classNames from "@lib/classNames";
type RadioAreaProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
onChange?: (value: string) => void;
defaultChecked?: boolean;
};
const RadioArea = (props: RadioAreaProps) => {
return (
<label
className={classNames(
"border-1 block border p-4 focus:outline-none focus:ring focus:ring-neutral-500",
props.checked && "border-brand",
props.className
)}>
<input
onChange={(e) => {
if (typeof props.onChange === "function") {
props.onChange(e.target.value);
}
}}
checked={props.checked}
className="float-right ml-3 text-neutral-900 focus:ring-neutral-500"
name={props.name}
value={props.value}
type="radio"
/>
{props.children}
</label>
);
};
type ChildrenProps = {
props: RadioAreaProps;
children?: ReactNode;
};
interface RadioAreaGroupProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
children: ChildrenProps | ChildrenProps[];
name?: string;
onChange?: (value: string) => void;
}
const RadioAreaGroup = ({ children, name, onChange, ...passThroughProps }: RadioAreaGroupProps) => {
const [checkedIdx, setCheckedIdx] = useState<number | null>(null);
const changeHandler = (value: string, idx: number) => {
if (onChange) {
onChange(value);
}
setCheckedIdx(idx);
};
return (
<div {...passThroughProps}>
{(Array.isArray(children) ? children : [children]).map((child, idx: number) => {
if (checkedIdx === null && child.props.defaultChecked) {
setCheckedIdx(idx);
}
return (
<Item
{...child.props}
key={idx}
name={name}
checked={idx === checkedIdx}
onChange={(value: string) => changeHandler(value, idx)}>
{child.props.children}
</Item>
);
})}
</div>
);
};
const Item = RadioArea;
const Group = RadioAreaGroup;
export { RadioArea, RadioAreaGroup, Item, Group };
@@ -1,61 +0,0 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import React from "react";
import { Icon } from "@calcom/ui";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { RadioArea, RadioAreaGroup } from "@components/ui/form/radio-area/RadioAreaGroup";
interface OptionProps
extends Pick<React.OptionHTMLAttributes<HTMLOptionElement>, "value" | "label" | "className"> {
description?: string;
}
interface RadioAreaSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
options: OptionProps[]; // allow options to be passed programmatically, like options={}
onChange?: (value: string) => void;
}
export const Select = function RadioAreaSelect(props: RadioAreaSelectProps) {
const { t } = useLocale();
const {
options,
disabled = !options.length, // if not explicitly disabled and the options length is empty, disable anyway
placeholder = t("select"),
} = props;
const getLabel = (value: string | ReadonlyArray<string> | number | undefined) =>
options.find((option: OptionProps) => option.value === value)?.label;
return (
<Collapsible className={classNames("w-full", props.className)}>
<CollapsibleTrigger
type="button"
disabled={disabled}
className={classNames(
"border-1 focus:ring-primary-500 mb-1 block w-full cursor-pointer rounded-sm border border-gray-300 bg-white p-2 text-left text-sm",
disabled && "cursor-default bg-gray-200 focus:ring-0 "
)}>
{getLabel(props.value) ?? placeholder}
<Icon.FiChevronDown className="float-right h-5 w-5 text-neutral-500" />
</CollapsibleTrigger>
<CollapsibleContent>
<RadioAreaGroup className="space-y-2 text-sm" name={props.name} onChange={props.onChange}>
{options.map((option) => (
<RadioArea
{...option}
key={Array.isArray(option.value) ? option.value.join(",") : `${option.value}`}
defaultChecked={props.value === option.value}>
<strong className="mb-1 block">{option.label}</strong>
<p>{option.description}</p>
</RadioArea>
))}
</RadioAreaGroup>
</CollapsibleContent>
</Collapsible>
);
};
export default Select;
@@ -1,2 +0,0 @@
export * from "./RadioAreaGroup";
export * from "./Select";
@@ -1,14 +1,15 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { SchedulingType } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import type { z } from "zod";
import { z } from "zod";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import slugify from "@calcom/lib/slugify";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
@@ -53,58 +54,50 @@ interface CreateEventTypeBtnProps {
options: EventTypeParent[];
}
const isValidJSONString = (str: string) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};
const locationFormSchema = z.array(
z.object({
locationType: z.string(),
locationAddress: z.string().optional(),
displayLocationPublicly: z.boolean().optional(),
locationPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field
})
);
export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
const querySchema = z.object({
eventPage: z.string(),
teamId: z.union([z.string().transform((val) => +val), z.number()]).optional(),
title: z.string().optional(),
slug: z.string().optional(),
length: z
.union([z.string().transform((val) => +val), z.number()])
.optional()
.default(15),
description: z.string().optional(),
schedulingType: z.nativeEnum(SchedulingType).optional(),
locations: z
.string()
.transform((jsonString) => locationFormSchema.parse(JSON.parse(jsonString)))
.optional(),
});
const CreateEventTypeDialog = () => {
const { t } = useLocale();
const router = useRouter();
// URL encoded params
const teamId: number | undefined =
typeof router.query.teamId === "string" && router.query.teamId
? parseInt(router.query.teamId)
: undefined;
const pageSlug = router.query.eventPage || props.options[0].slug;
const hasTeams = !!props.options.find((option) => option.teamId);
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
const {
data: { teamId, eventPage: pageSlug, ...defaultValues },
} = useTypedQuery(querySchema);
const form = useForm<z.infer<typeof createEventTypeInput>>({
resolver: zodResolver(createEventTypeInput),
defaultValues,
});
const { setValue, register } = form;
useEffect(() => {
if (!router.isReady) return;
const title: string =
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
const length: number =
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
const description: string =
typeof router.query.description === "string" && router.query.description
? router.query.description
: "";
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
const locations =
typeof router.query.locations === "string" &&
isValidJSONString(decodeURIComponent(router.query.locations))
? JSON.parse(decodeURIComponent(router.query.locations))
: [];
setValue("locations", locations);
setValue("title", title);
setValue("length", length);
setValue("description", description);
setValue("slug", slug);
// If query params change, update the form
}, [router.isReady, router.query, setValue]);
const { register } = form;
const createMutation = trpc.viewer.eventTypes.create.useMutation({
onSuccess: async ({ eventType }) => {
@@ -129,6 +122,142 @@ export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
},
});
return (
<Dialog
name="new-eventtype"
clearQueryParamsOnClose={[
"eventPage",
"teamId",
"type",
"description",
"title",
"length",
"slug",
"locations",
]}>
<DialogContent
type="creation"
className="overflow-y-auto"
title={teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
description={t("new_event_type_to_book_description")}>
<Form
form={form}
handleSubmit={(values) => {
createMutation.mutate(values);
}}>
<div className="mt-3 space-y-6">
{teamId && (
<TextField
type="hidden"
labelProps={{ style: { display: "none" } }}
{...register("teamId", { valueAsNumber: true })}
value={teamId}
/>
)}
<TextField
label={t("title")}
placeholder={t("quick_chat")}
{...register("title")}
onChange={(e) => {
form.setValue("title", e?.target.value);
if (form.formState.touchedFields["slug"] === undefined) {
form.setValue("slug", slugify(e?.target.value));
}
}}
/>
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{pageSlug}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
) : (
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</>
}
{...register("slug")}
/>
)}
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-20"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
{t("scheduling_type")}
</label>
{form.formState.errors.schedulingType && (
<Alert
className="mt-1"
severity="error"
message={form.formState.errors.schedulingType.message}
/>
)}
<RadioArea.Group className="mt-1 flex space-x-4">
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.COLLECTIVE}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.ROUND_ROBIN}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
)}
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose />
</div>
</Form>
</DialogContent>
</Dialog>
);
};
export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
const { t } = useLocale();
const router = useRouter();
const hasTeams = !!props.options.find((option) => option.teamId);
// inject selection data into url for correct router history
const openModal = (option: EventTypeParent) => {
const query = {
@@ -195,137 +324,7 @@ export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
)}
{/* Dialog for duplicate event type */}
{router.query.dialog === "duplicate-event-type" && <DuplicateDialog />}
{router.query.dialog === "new-eventtype" && (
<Dialog
name="new-eventtype"
clearQueryParamsOnClose={[
"eventPage",
"teamId",
"type",
"description",
"title",
"length",
"slug",
"locations",
]}>
<DialogContent
type="creation"
className="overflow-y-auto"
title={teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
description={t("new_event_type_to_book_description")}>
<Form
form={form}
handleSubmit={(values) => {
createMutation.mutate(values);
}}>
<div className="mt-3 space-y-6">
{teamId && (
<TextField
type="hidden"
labelProps={{ style: { display: "none" } }}
{...register("teamId", { valueAsNumber: true })}
value={teamId}
/>
)}
<TextField
label={t("title")}
placeholder={t("quick_chat")}
{...register("title")}
onChange={(e) => {
form.setValue("title", e?.target.value);
if (form.formState.touchedFields["slug"] === undefined) {
form.setValue("slug", slugify(e?.target.value));
}
}}
/>
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{pageSlug}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
) : (
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</>
}
{...register("slug")}
/>
)}
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-20"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
{t("scheduling_type")}
</label>
{form.formState.errors.schedulingType && (
<Alert
className="mt-1"
severity="error"
message={form.formState.errors.schedulingType.message}
/>
)}
<RadioArea.Group
{...register("schedulingType")}
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
<RadioArea.Item
value={SchedulingType.COLLECTIVE}
defaultChecked={type === SchedulingType.COLLECTIVE}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value={SchedulingType.ROUND_ROBIN}
defaultChecked={type === SchedulingType.ROUND_ROBIN}
className="w-1/2 text-sm">
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
)}
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose />
</div>
</Form>
</DialogContent>
</Dialog>
)}
{router.query.dialog === "new-eventtype" && <CreateEventTypeDialog />}
</>
);
}
@@ -0,0 +1,53 @@
import React from "react";
import classNames from "@calcom/lib/classNames";
type RadioAreaProps = React.InputHTMLAttributes<HTMLInputElement>;
const RadioArea = React.forwardRef<HTMLInputElement, RadioAreaProps>(
({ children, className, ...props }, ref) => {
return (
<label className={classNames("relative flex", className)}>
<input
ref={ref}
className="peer absolute top-[0.9rem] left-3 align-baseline text-neutral-900 focus:ring-neutral-500"
type="radio"
{...props}
/>
<div className="peer-checked:border-brand border-1 rounded-md border p-4 pt-3 pl-10 focus:outline-none focus:ring focus:ring-neutral-500">
{children}
</div>
</label>
);
}
);
interface RadioAreaGroupProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange"> {
onChange?: (value: string) => void;
}
const RadioAreaGroup = ({ children, className, onChange, ...passThroughProps }: RadioAreaGroupProps) => {
const childrenWithProps = React.Children.map(children, (child) => {
if (onChange && React.isValidElement(child)) {
return React.cloneElement(child, {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
});
}
return child;
});
return (
<div className={className} {...passThroughProps}>
{childrenWithProps}
</div>
);
};
RadioAreaGroup.displayName = "RadioAreaGroup";
RadioArea.displayName = "RadioArea";
const Item = RadioArea;
const Group = RadioAreaGroup;
export { RadioArea, RadioAreaGroup, Item, Group };
@@ -1,6 +1,7 @@
import { ChevronDownIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import React from "react";
import { FieldValues, Path, UseFormReturn } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -12,15 +13,22 @@ interface OptionProps
description?: string;
}
interface RadioAreaSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
export type FieldPath<TFieldValues extends FieldValues> = Path<TFieldValues>;
interface RadioAreaSelectProps<TFieldValues extends FieldValues>
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange" | "form"> {
options: OptionProps[]; // allow options to be passed programmatically, like options={}
onChange?: (value: string) => void;
form: UseFormReturn<TFieldValues>;
name: FieldPath<TFieldValues>;
}
export const Select = function RadioAreaSelect(props: RadioAreaSelectProps) {
export const Select = function RadioAreaSelect<TFieldValues extends FieldValues>(
props: RadioAreaSelectProps<TFieldValues>
) {
const { t } = useLocale();
const {
options,
form,
disabled = !options.length, // if not explicitly disabled and the options length is empty, disable anyway
placeholder = t("select"),
} = props;
@@ -41,12 +49,12 @@ export const Select = function RadioAreaSelect(props: RadioAreaSelectProps) {
<ChevronDownIcon className="float-right h-5 w-5 text-neutral-500" />
</CollapsibleTrigger>
<CollapsibleContent>
<RadioAreaGroup className="space-y-2 text-sm" name={props.name} onChange={props.onChange}>
<RadioAreaGroup className="space-y-2 text-sm" onChange={props.onChange}>
{options.map((option) => (
<RadioArea
{...form.register(props.name)}
{...option}
key={Array.isArray(option.value) ? option.value.join(",") : `${option.value}`}
defaultChecked={props.value === option.value}>
key={Array.isArray(option.value) ? option.value.join(",") : `${option.value}`}>
<strong className="mb-1 block">{option.label}</strong>
<p>{option.description}</p>
</RadioArea>
+8 -1
View File
@@ -114,7 +114,14 @@ export {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./v2/core/Dropdown";
export { RadioGroup, Radio, Group, RadioField } from "./v2/core/form";
export {
RadioGroup,
/* TODO: solve this conflict -> Select, */
Radio,
Group,
RadioField,
} from "./form/radio-area";
export { BooleanToggleGroupField } from "./v2/core/form/BooleanToggleGroup";
export { DateRangePickerLazy as DateRangePicker } from "./v2/core/form/date-range-picker";
export { default as DatePickerField } from "./v2/core/form/DatePicker";
+10 -1
View File
@@ -28,9 +28,18 @@ export function Dialog(props: DialogProps) {
if (open) {
router.query["dialog"] = name;
} else {
const query = router.query;
clearQueryParamsOnClose.forEach((queryParam) => {
delete router.query[queryParam];
delete query[queryParam];
});
router.push(
{
pathname: router.pathname,
query,
},
undefined,
{ shallow: true }
);
}
setOpen(open);
};
-7
View File
@@ -1,10 +1,3 @@
export {
RadioGroup,
/* TODO: solve this conflict -> Select, */
Radio,
Group,
RadioField,
} from "./radio-area";
export { default as Checkbox } from "../../../components/form/checkbox/Checkbox";
export { default as DatePicker } from "./DatePicker";
@@ -1,79 +0,0 @@
import React, { ReactNode, useState } from "react";
import classNames from "@calcom/lib/classNames";
type RadioAreaProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
onChange?: (value: string) => void;
defaultChecked?: boolean;
};
const RadioArea = (props: RadioAreaProps) => {
return (
<label
className={classNames(
"border-1 block border p-4 focus:outline-none focus:ring focus:ring-neutral-500",
props.checked && "border-brand",
props.className
)}>
<input
onChange={(e) => {
if (typeof props.onChange === "function") {
props.onChange(e.target.value);
}
}}
checked={props.checked}
className="float-right ml-3 text-neutral-900 focus:ring-neutral-500"
name={props.name}
value={props.value}
type="radio"
/>
{props.children}
</label>
);
};
type ChildrenProps = {
props: RadioAreaProps;
children?: ReactNode;
};
interface RadioAreaGroupProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
children: ChildrenProps | ChildrenProps[];
name?: string;
onChange?: (value: string) => void;
}
const RadioAreaGroup = ({ children, name, onChange, ...passThroughProps }: RadioAreaGroupProps) => {
const [checkedIdx, setCheckedIdx] = useState<number | null>(null);
const changeHandler = (value: string, idx: number) => {
if (onChange) {
onChange(value);
}
setCheckedIdx(idx);
};
return (
<div {...passThroughProps}>
{(Array.isArray(children) ? children : [children]).map((child, idx: number) => {
if (checkedIdx === null && child.props.defaultChecked) {
setCheckedIdx(idx);
}
return (
<Item
{...child.props}
key={idx}
name={name}
checked={idx === checkedIdx}
onChange={(value: string) => changeHandler(value, idx)}>
{child.props.children}
</Item>
);
})}
</div>
);
};
const Item = RadioArea;
const Group = RadioAreaGroup;
export { RadioArea, RadioAreaGroup, Item, Group };
+1 -1
View File
@@ -19,7 +19,7 @@ export {
DropdownMenuTrigger,
DropdownMenuTriggerItem,
} from "./Dropdown";
export { Checkbox, DatePicker, FormStep, Radio, RadioGroup } from "./form";
export { Checkbox, DatePicker, FormStep } from "./form";
export { default as LinkIconButton } from "./LinkIconButton";
export { default as Loader } from "./Loader";
export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones";