Added "New Event Type" button on Team settings (#1411)

- Moved CreateNewEventButton in pages/event-types/index to dedicated component as this is used in two places now.
- Implemented CreateEventType button on Team settings screen and replaced old markup in on event types page with new component.
- Upgrade vanilla JS inputs to library primitives.
- Created TextArea & TextAreaField components in components/form.
- [Bugfix] Changed back button behavior in Shell to have a specified back path as CreateEventType's modal interfered with the router.goBack behavior.
- Ensure modal data is preserved in URL params for router accuracy and removed on exit.
This commit is contained in:
Jamie Pine 2022-01-12 01:29:20 -08:00 committed by GitHub
parent 59d4d92b52
commit 70683a89b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 334 additions and 252 deletions

View file

@ -124,7 +124,7 @@ export default function Shell(props: {
children: ReactNode;
CTA?: ReactNode;
HeadingLeftIcon?: ReactNode;
showBackButton?: boolean;
backPath?: string; // renders back button to specified path
// use when content needs to expand with flex
flexChildrenContainer?: boolean;
}) {
@ -289,9 +289,12 @@ export default function Shell(props: {
props.flexChildrenContainer && "flex flex-col flex-1",
"py-8"
)}>
{props.showBackButton && (
{!!props.backPath && (
<div className="mx-3 mb-8 sm:mx-8">
<Button onClick={() => router.back()} StartIcon={ArrowLeftIcon} color="secondary">
<Button
onClick={() => router.push(props.backPath as string)}
StartIcon={ArrowLeftIcon}
color="secondary">
Back
</Button>
</div>

View file

@ -0,0 +1,242 @@
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useMutation } from "react-query";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import createEventType from "@lib/mutations/event-types/create-event-type";
import showToast from "@lib/notification";
import { CreateEventType } from "@lib/types/event-type";
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import { TextField, InputLeading, TextAreaField, Form } from "@components/form/fields";
import Avatar from "@components/ui/Avatar";
import { Button } from "@components/ui/Button";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/ui/Dropdown";
import * as RadioArea from "@components/ui/form/radio-area";
// this describes the uniform data needed to create a new event type on Profile or Team
interface EventTypeParent {
teamId: number | null | undefined; // if undefined, then it's a profile
name?: string | null;
slug?: string | null;
image?: string | null;
}
interface Props {
// set true for use on the team settings page
canAddEvents: boolean;
// set true when in use on the team settings page
isIndividualTeam?: boolean;
// EventTypeParent can be a profile (as first option) or a team for the rest.
options: EventTypeParent[];
}
export default function CreateEventTypeButton(props: Props) {
const { t } = useLocale();
const router = useRouter();
const modalOpen = useToggleQuery("new");
const form = useForm<CreateEventType>({
defaultValues: { length: 15 },
});
const { setValue, watch, register } = form;
useEffect(() => {
const subscription = watch((value, { name, type }) => {
if (name === "title" && type === "change") {
if (value.title) setValue("slug", value.title.replace(/\s+/g, "-").toLowerCase());
else setValue("slug", "");
}
});
return () => subscription.unsubscribe();
}, [watch, setValue]);
// URL encoded params
const teamId: number | null = Number(router.query.teamId) || null;
const pageSlug = router.query.eventPage || props.options[0].slug;
const hasTeams = !!props.options.find((option) => option.teamId);
const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id);
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
},
});
// inject selection data into url for correct router history
const openModal = (option: EventTypeParent) => {
router.push({
pathname: router.pathname,
query: {
...router.query,
new: "1",
eventPage: option.slug,
...(option.teamId
? {
teamId: option.teamId,
}
: {}),
},
});
};
// remove url params after close modal to reset state
const closeModal = () => {
router.replace({
pathname: router.pathname,
query: { id: router.query.id },
});
};
return (
<Dialog
open={modalOpen.isOn}
onOpenChange={(isOpen) => {
router.push(isOpen ? modalOpen.hrefOn : modalOpen.hrefOff);
if (!isOpen) closeModal();
}}>
{!hasTeams || props.isIndividualTeam ? (
<Button
onClick={() => openModal(props.options[0])}
data-testid="new-event-type"
StartIcon={PlusIcon}
{...(props.canAddEvents
? {
href: modalOpen.hrefOn,
}
: {
disabled: true,
})}>
{t("new_event_type_btn")}
</Button>
) : (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button EndIcon={ChevronDownIcon}>{t("new_event_type_btn")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("new_event_subtitle")}</DropdownMenuLabel>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{props.options.map((option) => (
<DropdownMenuItem
key={option.slug}
className="px-3 py-2 cursor-pointer hover:bg-neutral-100 focus:outline-none"
onSelect={() => openModal(option)}>
<Avatar alt={option.name || ""} imageSrc={option.image} size={6} className="inline mr-2" />
{option.name ? option.name : option.slug}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
)}
<DialogContent>
<div className="mb-4">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
</div>
</div>
<Form
form={form}
handleSubmit={(values) => {
const payload: CreateEventType = {
title: values.title,
slug: values.slug,
description: values.description,
length: values.length,
};
if (router.query.teamId) {
payload.teamId = parseInt(`${router.query.teamId}`, 10);
payload.schedulingType = values.schedulingType as SchedulingType;
}
createMutation.mutate(payload);
}}>
<div className="mt-3 space-y-4">
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
<TextField
label={t("url")}
required
addOnLeading={
<InputLeading>
{process.env.NEXT_PUBLIC_APP_URL}/{pageSlug}/
</InputLeading>
}
{...register("slug")}
/>
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
placeholder="15"
defaultValue={15}
label={t("length")}
className="pr-20"
{...register("length")}
/>
<div className="absolute inset-y-0 right-0 flex items-center pt-4 mt-1.5 pr-3 text-sm text-gray-400">
{t("minutes")}
</div>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
{t("scheduling_type")}
</label>
<RadioArea.Group
{...register("schedulingType")}
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
className="relative flex mt-1 space-x-6 rounded-sm shadow-sm">
<RadioArea.Item value={SchedulingType.COLLECTIVE} className="w-1/2 text-sm">
<strong className="block mb-1">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item value={SchedulingType.ROUND_ROBIN} className="w-1/2 text-sm">
<strong className="block mb-1">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
)}
</div>
<div className="flex flex-row-reverse mt-8 gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
</div>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -10,6 +10,7 @@ import showToast from "@lib/notification";
import { Alert } from "@components/ui/Alert";
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return (
<input
@ -31,6 +32,14 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
);
}
export function InputLeading(props: JSX.IntrinsicElements["div"]) {
return (
<span className="inline-flex items-center flex-shrink-0 px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{props.children}
</span>
);
}
type InputFieldProps = {
label?: ReactNode;
addOnLeading?: ReactNode;
@ -50,7 +59,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
: "",
className,
addOnLeading,
...passThroughToInput
...passThrough
} = props;
return (
<div>
@ -65,13 +74,13 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
<Input
id={id}
placeholder={placeholder}
className={classNames(className, "mt-0")}
{...passThroughToInput}
className={classNames(className, "mt-0", props.addOnLeading && "rounded-l-none")}
{...passThrough}
ref={ref}
/>
</div>
) : (
<Input id={id} placeholder={placeholder} className={className} {...passThroughToInput} ref={ref} />
<Input id={id} placeholder={placeholder} className={className} {...passThrough} ref={ref} />
)}
{methods?.formState?.errors[props.name] && (
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
@ -112,6 +121,57 @@ export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function
return <EmailInput ref={ref} {...props} />;
});
type TextAreaProps = Omit<JSX.IntrinsicElements["textarea"], "name"> & { name: string };
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextAreaInput(props, ref) {
return (
<textarea
ref={ref}
{...props}
className={classNames(
"block w-full font-mono border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm",
props.className
)}
/>
);
});
type TextAreaFieldProps = {
label?: ReactNode;
} & React.ComponentProps<typeof TextArea> & {
labelProps?: React.ComponentProps<typeof Label>;
};
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextField(
props,
ref
) {
const id = useId();
const { t } = useLocale();
const methods = useFormContext();
const {
label = t(props.name as string),
labelProps,
placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
? t(props.name + "_placeholder")
: "",
...passThrough
} = props;
return (
<div>
{!!props.name && (
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
)}
<TextArea ref={ref} placeholder={placeholder} {...passThrough} />
{methods?.formState?.errors[props.name] && (
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
)}
</div>
);
});
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
JSX.IntrinsicElements["form"],
"onSubmit"

View file

@ -10,6 +10,7 @@ import { trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import LinkIconButton from "@components/ui/LinkIconButton";
import { MembershipRole } from ".prisma/client";
@ -49,6 +50,13 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
return (
<div className="px-2 space-y-6">
<CreateEventTypeButton
isIndividualTeam
canAddEvents={true}
options={[
{ teamId: props.team?.id, name: props.team?.name, slug: props.team?.slug, image: props.team?.logo },
]}
/>
{/* <Switch
name="isHidden"
defaultChecked={hidden}

View file

@ -33,7 +33,7 @@ export function TeamAvailabilityPage() {
return (
<Shell
showBackButton={!errorMessage}
backPath={!errorMessage ? `/settings/teams/${team?.id}` : undefined}
heading={!isFreeUser && team?.name}
flexChildrenContainer
subtitle={team && !isFreeUser && "Your team's availability at a glance"}

View file

@ -1,49 +1,31 @@
// TODO: replace headlessui with radix-ui
import { Menu, Transition } from "@headlessui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
DotsHorizontalIcon,
ExternalLinkIcon,
LinkIcon,
ArrowDownIcon,
ChevronDownIcon,
PlusIcon,
ArrowUpIcon,
UsersIcon,
} from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useRef, useState, useEffect } from "react";
import { useMutation } from "react-query";
import React, { Fragment, useEffect, useState } from "react";
import { QueryCell } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import createEventType from "@lib/mutations/event-types/create-event-type";
import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { CreateEventType } from "@lib/types/event-type";
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import Shell from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import Badge from "@components/ui/Badge";
import { Button } from "@components/ui/Button";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/ui/Dropdown";
import * as RadioArea from "@components/ui/form/radio-area";
import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration";
type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"];
@ -64,7 +46,7 @@ const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypePro
<div className="block mx-auto text-center md:max-w-screen-sm">
<h3 className="mt-2 text-xl font-bold text-neutral-900">{t("new_event_type_heading")}</h3>
<p className="mt-1 mb-2 text-md text-neutral-600">{t("new_event_type_description")}</p>
<CreateNewEventButton canAddEvents={canAddEvents} profiles={profiles} />
<CreateEventTypeButton canAddEvents={canAddEvents} options={profiles} />
</div>
</div>
);
@ -332,9 +314,9 @@ const EventTypesPage = () => {
CTA={
query.data &&
query.data.eventTypeGroups.length !== 0 && (
<CreateNewEventButton
<CreateEventTypeButton
canAddEvents={query.data.viewer.canAddEvents}
profiles={query.data.profiles}
options={query.data.profiles}
/>
)
}>
@ -385,217 +367,4 @@ const EventTypesPage = () => {
);
};
const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps) => {
const router = useRouter();
const teamId: number | null = Number(router.query.teamId) || null;
const modalOpen = useToggleQuery("new");
const { t } = useLocale();
const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id);
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
},
});
const slugRef = useRef<HTMLInputElement>(null);
return (
<Dialog
open={modalOpen.isOn}
onOpenChange={(isOpen) => {
router.push(isOpen ? modalOpen.hrefOn : modalOpen.hrefOff);
}}>
{!profiles.filter((profile) => profile.teamId).length && (
<Button
data-testid="new-event-type"
{...(canAddEvents
? {
href: modalOpen.hrefOn,
}
: {
disabled: true,
})}
StartIcon={PlusIcon}>
{t("new_event_type_btn")}
</Button>
)}
{profiles.filter((profile) => profile.teamId).length > 0 && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button EndIcon={ChevronDownIcon}>{t("new_event_type_btn")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("new_event_subtitle")}</DropdownMenuLabel>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{profiles.map((profile) => (
<DropdownMenuItem
key={profile.slug}
className="px-3 py-2 cursor-pointer hover:bg-neutral-100 focus:outline-none"
onSelect={() =>
router.push({
pathname: router.pathname,
query: {
...router.query,
new: "1",
eventPage: profile.slug,
...(profile.teamId
? {
teamId: profile.teamId,
}
: {}),
},
})
}>
<Avatar alt={profile.name || ""} imageSrc={profile.image} size={6} className="inline mr-2" />
{profile.name ? profile.name : profile.slug}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
)}
<DialogContent>
<div className="mb-8">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const target = e.target as unknown as Record<
"title" | "slug" | "description" | "length" | "schedulingType",
{ value: string }
>;
const payload: CreateEventType = {
title: target.title.value,
slug: target.slug.value,
description: target.description.value,
length: parseInt(target.length.value),
};
if (router.query.teamId) {
payload.teamId = parseInt(`${router.query.teamId}`, 10);
payload.schedulingType = target.schedulingType.value as SchedulingType;
}
createMutation.mutate(payload);
}}>
<div>
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
{t("title")}
</label>
<div className="mt-1">
<input
onChange={(e) => {
if (!slugRef.current) {
return;
}
slugRef.current.value = e.target.value.replace(/\s+/g, "-").toLowerCase();
}}
type="text"
name="title"
id="title"
required
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
placeholder={t("quick_chat")}
/>
</div>
</div>
<div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">
{t("url")}
</label>
<div className="mt-1">
<div className="flex rounded-sm shadow-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-md bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/{router.query.eventPage || profiles[0].slug}/
</span>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="flex-1 block w-full min-w-0 border-gray-300 rounded-none focus:ring-neutral-900 focus:border-neutral-900 rounded-r-md sm:text-sm"
/>
</div>
</div>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
{t("description")}
</label>
<div className="mt-1">
<textarea
name="description"
id="description"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
placeholder={t("quick_video_meeting")}
/>
</div>
</div>
<div className="mb-4">
<label htmlFor="length" className="block text-sm font-medium text-gray-700">
{t("length")}
</label>
<div className="relative mt-1 rounded-sm shadow-sm">
<input
type="number"
name="length"
id="length"
required
className="block w-full pr-20 border-gray-300 rounded-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
placeholder="15"
defaultValue={15}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-gray-400">
{t("minutes")}
</div>
</div>
</div>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-medium text-gray-700">
{t("scheduling_type")}
</label>
<RadioArea.Group
name="schedulingType"
className="relative flex mt-1 space-x-6 rounded-sm shadow-sm">
<RadioArea.Item value={SchedulingType.COLLECTIVE} className="w-1/2 text-sm">
<strong className="block mb-1">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item value={SchedulingType.ROUND_ROBIN} className="w-1/2 text-sm">
<strong className="block mb-1">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
)}
<div className="flex flex-row-reverse mt-8 gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
</div>
</form>
</DialogContent>
</Dialog>
);
};
export default EventTypesPage;

View file

@ -19,7 +19,7 @@ import Loader from "@components/Loader";
import Shell, { ShellSubHeading } from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { FieldsetLegend, Form, InputGroupBox, TextField } from "@components/form/fields";
import { FieldsetLegend, Form, InputGroupBox, TextField, TextArea } from "@components/form/fields";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import ConnectIntegration from "@components/integrations/ConnectIntegrations";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
@ -270,11 +270,11 @@ function WebhookDialogForm(props: {
</label>
</div>
{useCustomPayloadTemplate && (
<textarea
<TextArea
{...form.register("payloadTemplate")}
className="block w-full font-mono border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
rows={5}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}></textarea>
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />

View file

@ -33,7 +33,7 @@ export function TeamSettingsPage() {
return (
<Shell
showBackButton={!errorMessage}
backPath={!errorMessage ? `/settings/teams` : undefined}
heading={team?.name}
subtitle={team && "Manage this team"}
HeadingLeftIcon={