From 85d7122e43c427622ddb8d720e6631217249aeea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Wed, 20 Oct 2021 09:42:40 -0600 Subject: [PATCH] Fixes Apple Calendar onboarding and type fixes (#988) * Type fixes * Type fixes * Attemp to prevent unknown error in prod * Type fixes * Type fixes for onboarding * Extracts ConnectIntegration * Extracts IntegrationListItem * Extracts CalendarsList * Uses CalendarList on onboarding * Removes deprecated Alert * Extracts DisconnectIntegration * Extracts CalendarSwitch * Extracts ConnectedCalendarsList * Extracted connectedCalendar logic for reuse * Extracted SubHeadingTitleWithConnections * Type fixes * Fetched connected calendars in onboarding * Refreshes data on when adding/removing calendars on onboarding * Removed testing code * Type fixes * Feedback * Moved integration helpers * I was sleepy Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- components/integrations/CalendarSwitch.tsx | 75 ++++ components/integrations/CalendarsList.tsx | 45 +++ .../integrations/ConnectIntegrations.tsx | 56 +++ .../integrations/ConnectedCalendarsList.tsx | 98 ++++++ .../integrations/DisconnectIntegration.tsx | 60 ++++ .../integrations/IntegrationListItem.tsx | 30 ++ .../SubHeadingTitleWithConnections.tsx | 29 ++ components/ui/UsernameInput.tsx | 6 +- components/ui/WeekdaySelect.tsx | 26 +- .../ui/form/radio-area/RadioAreaGroup.tsx | 64 ++-- .../api/integrations/stripepayment/webhook.ts | 2 +- lib/core/i18n/i18n.utils.ts | 6 +- lib/errors.ts | 11 + package.json | 3 +- pages/_error.tsx | 18 +- pages/event-types/index.tsx | 4 +- pages/getting-started.tsx | 282 +++++---------- pages/integrations/index.tsx | 321 ++---------------- pages/reschedule/[uid].tsx | 6 +- pages/settings/profile.tsx | 21 +- pages/settings/security.tsx | 2 +- server/integrations/getCalendarCredentials.ts | 25 ++ server/integrations/getConnectedCalendars.ts | 50 +++ server/routers/viewer.tsx | 56 +-- server/ssg.ts | 7 + test/lib/slots.test.ts | 4 +- yarn.lock | 5 + 27 files changed, 698 insertions(+), 614 deletions(-) create mode 100644 components/integrations/CalendarSwitch.tsx create mode 100644 components/integrations/CalendarsList.tsx create mode 100644 components/integrations/ConnectIntegrations.tsx create mode 100644 components/integrations/ConnectedCalendarsList.tsx create mode 100644 components/integrations/DisconnectIntegration.tsx create mode 100644 components/integrations/IntegrationListItem.tsx create mode 100644 components/integrations/SubHeadingTitleWithConnections.tsx create mode 100644 lib/errors.ts create mode 100644 server/integrations/getCalendarCredentials.ts create mode 100644 server/integrations/getConnectedCalendars.ts diff --git a/components/integrations/CalendarSwitch.tsx b/components/integrations/CalendarSwitch.tsx new file mode 100644 index 0000000000..9e4330d9b2 --- /dev/null +++ b/components/integrations/CalendarSwitch.tsx @@ -0,0 +1,75 @@ +import { useMutation } from "react-query"; + +import showToast from "@lib/notification"; +import { trpc } from "@lib/trpc"; + +import Switch from "@components/ui/Switch"; + +export default function CalendarSwitch(props: { + type: string; + externalId: string; + title: string; + defaultSelected: boolean; +}) { + const utils = trpc.useContext(); + + const mutation = useMutation< + unknown, + unknown, + { + isOn: boolean; + } + >( + async ({ isOn }) => { + const body = { + integration: props.type, + externalId: props.externalId, + }; + if (isOn) { + const res = await fetch("/api/availability/calendar", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error("Something went wrong"); + } + } else { + const res = await fetch("/api/availability/calendar", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error("Something went wrong"); + } + } + }, + { + async onSettled() { + await utils.invalidateQueries(["viewer.integrations"]); + }, + onError() { + showToast(`Something went wrong when toggling "${props.title}""`, "error"); + }, + } + ); + return ( +
+ { + mutation.mutate({ isOn }); + }} + /> +
+ ); +} diff --git a/components/integrations/CalendarsList.tsx b/components/integrations/CalendarsList.tsx new file mode 100644 index 0000000000..c845dad3f6 --- /dev/null +++ b/components/integrations/CalendarsList.tsx @@ -0,0 +1,45 @@ +import React, { ReactNode } from "react"; + +import { List } from "@components/List"; +import Button from "@components/ui/Button"; + +import ConnectIntegration from "./ConnectIntegrations"; +import IntegrationListItem from "./IntegrationListItem"; + +interface Props { + calendars: { + children?: ReactNode; + description: string; + imageSrc: string; + title: string; + type: string; + }[]; + onChanged: () => void | Promise; +} + +const CalendarsList = (props: Props): JSX.Element => { + const { calendars, onChanged } = props; + return ( + + {calendars.map((item) => ( + ( + + )} + onOpenChange={onChanged} + /> + } + /> + ))} + + ); +}; + +export default CalendarsList; diff --git a/components/integrations/ConnectIntegrations.tsx b/components/integrations/ConnectIntegrations.tsx new file mode 100644 index 0000000000..40b10d90f7 --- /dev/null +++ b/components/integrations/ConnectIntegrations.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { useMutation } from "react-query"; + +import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration"; +import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration"; + +import { ButtonBaseProps } from "@components/ui/Button"; + +export default function ConnectIntegration(props: { + type: string; + render: (renderProps: ButtonBaseProps) => JSX.Element; + onOpenChange: (isOpen: boolean) => void | Promise; +}) { + const { type } = props; + const [isLoading, setIsLoading] = useState(false); + const mutation = useMutation(async () => { + const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add"); + if (!res.ok) { + throw new Error("Something went wrong"); + } + const json = await res.json(); + window.location.href = json.url; + setIsLoading(true); + }); + const [isModalOpen, _setIsModalOpen] = useState(false); + + const setIsModalOpen = (v: boolean) => { + _setIsModalOpen(v); + props.onOpenChange(v); + }; + + return ( + <> + {props.render({ + onClick() { + if (["caldav_calendar", "apple_calendar"].includes(type)) { + // special handlers + setIsModalOpen(true); + return; + } + + mutation.mutate(); + }, + loading: mutation.isLoading || isLoading, + disabled: isModalOpen, + })} + {type === "caldav_calendar" && ( + + )} + + {type === "apple_calendar" && ( + + )} + + ); +} diff --git a/components/integrations/ConnectedCalendarsList.tsx b/components/integrations/ConnectedCalendarsList.tsx new file mode 100644 index 0000000000..5c1c185a67 --- /dev/null +++ b/components/integrations/ConnectedCalendarsList.tsx @@ -0,0 +1,98 @@ +import React, { Fragment, ReactNode } from "react"; + +import { List } from "@components/List"; +import { Alert } from "@components/ui/Alert"; +import Button from "@components/ui/Button"; + +import CalendarSwitch from "./CalendarSwitch"; +import DisconnectIntegration from "./DisconnectIntegration"; +import IntegrationListItem from "./IntegrationListItem"; + +type CalIntersection = + | { + calendars: { + externalId: string; + name: string; + isSelected: boolean; + }[]; + error?: never; + } + | { + calendars?: never; + error: { + message: string; + }; + }; + +type Props = { + onChanged: (isOpen: boolean) => void | Promise; + connectedCalendars: (CalIntersection & { + credentialId: number; + integration: { + type: string; + imageSrc: string; + title: string; + children?: ReactNode; + }; + primary?: { externalId: string } | undefined | null; + })[]; +}; + +const ConnectedCalendarsList = (props: Props): JSX.Element => { + const { connectedCalendars, onChanged } = props; + return ( + + {connectedCalendars.map((item) => ( + + {item.calendars ? ( + ( + + )} + onOpenChange={onChanged} + /> + }> +
    + {item.calendars.map((cal) => ( + + ))} +
+
+ ) : ( + ( + + )} + onOpenChange={onChanged} + /> + } + /> + )} +
+ ))} +
+ ); +}; + +export default ConnectedCalendarsList; diff --git a/components/integrations/DisconnectIntegration.tsx b/components/integrations/DisconnectIntegration.tsx new file mode 100644 index 0000000000..f656de80a2 --- /dev/null +++ b/components/integrations/DisconnectIntegration.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { useMutation } from "react-query"; + +import { Dialog } from "@components/Dialog"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import { ButtonBaseProps } from "@components/ui/Button"; + +export default function DisconnectIntegration(props: { + /** Integration credential id */ + id: number; + render: (renderProps: ButtonBaseProps) => JSX.Element; + onOpenChange: (isOpen: boolean) => void | Promise; +}) { + const [modalOpen, setModalOpen] = useState(false); + const mutation = useMutation( + async () => { + const res = await fetch("/api/integrations", { + method: "DELETE", + body: JSON.stringify({ id: props.id }), + headers: { + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new Error("Something went wrong"); + } + }, + { + async onSettled() { + props.onOpenChange(modalOpen); + }, + onSuccess() { + setModalOpen(false); + }, + } + ); + return ( + <> + + { + mutation.mutate(); + }}> + Are you sure you want to disconnect this integration? + + + {props.render({ + onClick() { + setModalOpen(true); + }, + disabled: modalOpen, + loading: mutation.isLoading, + })} + + ); +} diff --git a/components/integrations/IntegrationListItem.tsx b/components/integrations/IntegrationListItem.tsx new file mode 100644 index 0000000000..ae11c7b2d5 --- /dev/null +++ b/components/integrations/IntegrationListItem.tsx @@ -0,0 +1,30 @@ +import Image from "next/image"; +import { ReactNode } from "react"; + +import classNames from "@lib/classNames"; + +import { ListItem, ListItemText, ListItemTitle } from "@components/List"; + +function IntegrationListItem(props: { + imageSrc: string; + title: string; + description: string; + actions?: ReactNode; + children?: ReactNode; +}): JSX.Element { + return ( + +
+ {props.title} +
+ {props.title} + {props.description} +
+
{props.actions}
+
+ {props.children &&
{props.children}
} +
+ ); +} + +export default IntegrationListItem; diff --git a/components/integrations/SubHeadingTitleWithConnections.tsx b/components/integrations/SubHeadingTitleWithConnections.tsx new file mode 100644 index 0000000000..80d120d301 --- /dev/null +++ b/components/integrations/SubHeadingTitleWithConnections.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; + +import Badge from "@components/ui/Badge"; + +function pluralize(opts: { num: number; plural: string; singular: string }) { + if (opts.num === 0) { + return opts.singular; + } + return opts.singular; +} + +export default function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) { + const num = props.numConnections; + return ( + <> + {props.title} + {num ? ( + + {num}{" "} + {pluralize({ + num, + singular: "connection", + plural: "connections", + })} + + ) : null} + + ); +} diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx index fa458e03f4..cd3b615f0d 100644 --- a/components/ui/UsernameInput.tsx +++ b/components/ui/UsernameInput.tsx @@ -1,6 +1,10 @@ import React from "react"; -const UsernameInput = React.forwardRef((props, ref) => ( +interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> { + label?: string; +} + +const UsernameInput = React.forwardRef((props, ref) => ( // todo, check if username is already taken here?
- - ); - }; - /** End Internal Components */ - /** Name */ - const nameRef = useRef(null); - const bioRef = useRef(null); + const nameRef = useRef(null); + const bioRef = useRef(null); /** End Name */ /** TimeZone */ const [selectedTimeZone, setSelectedTimeZone] = useState({ @@ -183,88 +132,6 @@ export default function Onboarding(props: OnboardingProps) { }, [selectedTimeZone]); /** End TimeZone */ - /** CalDav Form */ - const addCalDavIntegrationRef = useRef(null); - const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false); - const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null); - - const handleAddCalDavIntegration = async ({ url, username, password }: AddCalDavIntegrationRequest) => { - const requestBody = JSON.stringify({ - url, - username, - password, - }); - - return await fetch("/api/integrations/caldav/add", { - method: "POST", - body: requestBody, - headers: { - "Content-Type": "application/json", - }, - }); - }; - - const handleAddCalDavIntegrationSaveButtonPress = async () => { - const form = addCalDavIntegrationRef.current.elements; - const url = form.url.value; - const password = form.password.value; - const username = form.username.value; - - try { - setAddCalDavError(null); - const addCalDavIntegrationResponse = await handleAddCalDavIntegration({ username, password, url }); - if (addCalDavIntegrationResponse.ok) { - setIsAddCalDavIntegrationDialogOpen(false); - incrementStep(); - } else { - const j = await addCalDavIntegrationResponse.json(); - setAddCalDavError({ message: j.message }); - } - } catch (reason) { - console.error(reason); - } - }; - - const ConnectCalDavServerDialog = () => { - return ( - setIsAddCalDavIntegrationDialogOpen(isOpen)}> - - -
- {addCalDavError && ( -

- {t("error")}: - {addCalDavError.message} -

- )} - -
-
- - { - setIsAddCalDavIntegrationDialogOpen(false); - }} - asChild> - - -
-
-
- ); - }; - /**End CalDav Form */ - /** Onboarding Steps */ const [currentStep, setCurrentStep] = useState(0); const detectStep = () => { @@ -274,7 +141,7 @@ export default function Onboarding(props: OnboardingProps) { step = 1; } - const hasConfigureCalendar = props.integrations.some((integration) => integration.credential != null); + const hasConfigureCalendar = props.integrations.some((integration) => integration.credential !== null); if (hasConfigureCalendar) { step = 2; } @@ -292,17 +159,17 @@ export default function Onboarding(props: OnboardingProps) { setSubmitting(true); if ( steps[currentStep] && - steps[currentStep]?.onComplete && - typeof steps[currentStep]?.onComplete === "function" + steps[currentStep].onComplete && + typeof steps[currentStep].onComplete === "function" ) { - await steps[currentStep].onComplete(); + await steps[currentStep].onComplete!(); } incrementStep(); setSubmitting(false); } catch (error) { console.log("handleConfirmStep", error); setSubmitting(false); - setError(error); + setError(error as Error); } }; @@ -385,7 +252,7 @@ export default function Onboarding(props: OnboardingProps) { placeholder={t("your_name")} defaultValue={props.user.name ?? enteredName} required - className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" + className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" /> @@ -403,7 +270,7 @@ export default function Onboarding(props: OnboardingProps) { id="timeZone" value={selectedTimeZone} onChange={setSelectedTimeZone} - className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" /> @@ -417,13 +284,13 @@ export default function Onboarding(props: OnboardingProps) { try { setSubmitting(true); await updateUser({ - name: nameRef.current.value, + name: nameRef.current?.value, timeZone: selectedTimeZone.value, }); - setEnteredName(nameRef.current.value); + setEnteredName(nameRef.current?.value || ""); setSubmitting(true); } catch (error) { - setError(error); + setError(error as Error); setSubmitting(false); } }, @@ -433,11 +300,28 @@ export default function Onboarding(props: OnboardingProps) { title: t("connect_your_calendar"), description: t("connect_your_calendar_instructions"), Component: ( -
    - {props.integrations.map((integration) => { - return ; - })} -
+ <> + {props.connectedCalendars.length > 0 && ( + <> + { + refreshData(); + }} + /> + } + /> + + )} + { + refreshData(); + }} + /> + ), hideConfirm: true, confirmText: t("continue"), @@ -450,7 +334,7 @@ export default function Onboarding(props: OnboardingProps) { description: t("set_availability_instructions"), Component: ( <> -
+
{ try { @@ -461,12 +345,12 @@ export default function Onboarding(props: OnboardingProps) { debouncedHandleConfirmStep(); setSubmitting(false); } catch (error) { - setError(error); + setError(error as Error); } }} />
-