diff --git a/.vscode/settings.json b/.vscode/settings.json index 4bb9b697..404259b4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,9 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, - "eslint.run": "onSave" + "eslint.run": "onSave", + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#888888", + "titleBar.inactiveBackground": "#292929" + } } diff --git a/components/ClientSuspense.tsx b/components/ClientSuspense.tsx new file mode 100644 index 00000000..f0723d2e --- /dev/null +++ b/components/ClientSuspense.tsx @@ -0,0 +1,9 @@ +import { Suspense, SuspenseProps } from "react"; + +/** + * Wrapper around `` which will render the `fallback` when on server + * Can be simply replaced by `` once React 18 is ready. + */ +export const ClientSuspense = (props: SuspenseProps) => { + return <>{typeof window !== "undefined" ? : props.fallback}>; +}; diff --git a/components/integrations/CalendarListContainer.tsx b/components/integrations/CalendarListContainer.tsx new file mode 100644 index 00000000..14244690 --- /dev/null +++ b/components/integrations/CalendarListContainer.tsx @@ -0,0 +1,220 @@ +import React, { Fragment } from "react"; +import { useMutation } from "react-query"; + +import { QueryCell } from "@lib/QueryCell"; +import showToast from "@lib/notification"; +import { trpc } from "@lib/trpc"; + +import { List } from "@components/List"; +import { ShellSubHeading } from "@components/Shell"; +import { Alert } from "@components/ui/Alert"; +import Button from "@components/ui/Button"; +import Switch from "@components/ui/Switch"; + +import ConnectIntegration from "./ConnectIntegrations"; +import DisconnectIntegration from "./DisconnectIntegration"; +import IntegrationListItem from "./IntegrationListItem"; +import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections"; + +type Props = { + onChanged: () => unknown | Promise; +}; + +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 }); + }} + /> + + ); +} + +function ConnectedCalendarsList(props: Props) { + const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true }); + + return ( + null} + success={({ data }) => ( + + {data.map((item) => ( + + {item.calendars ? ( + ( + + Disconnect + + )} + onOpenChange={props.onChanged} + /> + }> + + {item.calendars.map((cal) => ( + + ))} + + + ) : ( + ( + + Disconnect + + )} + onOpenChange={() => props.onChanged()} + /> + } + /> + )} + + ))} + + )} + /> + ); +} + +function CalendarList(props: Props) { + const query = trpc.useQuery(["viewer.integrations"]); + + return ( + ( + + {data.calendar.items.map((item) => ( + ( + + Connect + + )} + onOpenChange={() => props.onChanged()} + /> + } + /> + ))} + + )} + /> + ); +} +export function CalendarListContainer(props: { heading?: false }) { + const { heading = true } = props; + const utils = trpc.useContext(); + const onChanged = () => + Promise.allSettled([ + utils.invalidateQueries(["viewer.integrations"]), + utils.invalidateQueries(["viewer.connectedCalendars"]), + ]); + const query = trpc.useQuery(["viewer.connectedCalendars"]); + return ( + <> + {heading && ( + } + subtitle={ + <> + Configure how your links integrate with your calendars. + + You can override these settings on a per event basis. + > + } + /> + )} + + {!!query.data?.length && ( + } + /> + )} + + > + ); +} diff --git a/components/integrations/CalendarSwitch.tsx b/components/integrations/CalendarSwitch.tsx deleted file mode 100644 index 9e4330d9..00000000 --- a/components/integrations/CalendarSwitch.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index c845dad3..00000000 --- a/components/integrations/CalendarsList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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) => ( - ( - - Connect - - )} - onOpenChange={onChanged} - /> - } - /> - ))} - - ); -}; - -export default CalendarsList; diff --git a/components/integrations/ConnectIntegrations.tsx b/components/integrations/ConnectIntegrations.tsx index 40b10d90..3e96f7e7 100644 --- a/components/integrations/ConnectIntegrations.tsx +++ b/components/integrations/ConnectIntegrations.tsx @@ -9,7 +9,7 @@ import { ButtonBaseProps } from "@components/ui/Button"; export default function ConnectIntegration(props: { type: string; render: (renderProps: ButtonBaseProps) => JSX.Element; - onOpenChange: (isOpen: boolean) => void | Promise; + onOpenChange: (isOpen: boolean) => unknown | Promise; }) { const { type } = props; const [isLoading, setIsLoading] = useState(false); diff --git a/components/integrations/ConnectedCalendarsList.tsx b/components/integrations/ConnectedCalendarsList.tsx deleted file mode 100644 index 5c1c185a..00000000 --- a/components/integrations/ConnectedCalendarsList.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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 ? ( - ( - - Disconnect - - )} - onOpenChange={onChanged} - /> - }> - - {item.calendars.map((cal) => ( - - ))} - - - ) : ( - ( - - Disconnect - - )} - onOpenChange={onChanged} - /> - } - /> - )} - - ))} - - ); -}; - -export default ConnectedCalendarsList; diff --git a/components/integrations/DisconnectIntegration.tsx b/components/integrations/DisconnectIntegration.tsx index d3ce8e67..9b3e3db2 100644 --- a/components/integrations/DisconnectIntegration.tsx +++ b/components/integrations/DisconnectIntegration.tsx @@ -9,7 +9,7 @@ export default function DisconnectIntegration(props: { /** Integration credential id */ id: number; render: (renderProps: ButtonBaseProps) => JSX.Element; - onOpenChange: (isOpen: boolean) => void | Promise; + onOpenChange: (isOpen: boolean) => unknown | Promise; }) { const [modalOpen, setModalOpen] = useState(false); const mutation = useMutation( diff --git a/lib/QueryCell.tsx b/lib/QueryCell.tsx index 84a7a45b..733c64df 100644 --- a/lib/QueryCell.tsx +++ b/lib/QueryCell.tsx @@ -13,36 +13,37 @@ import { Alert } from "@components/ui/Alert"; type ErrorLike = { message: string; }; +type JSXElementOrNull = JSX.Element | null; interface QueryCellOptionsBase { query: UseQueryResult; error?: ( query: QueryObserverLoadingErrorResult | QueryObserverRefetchErrorResult - ) => JSX.Element; - loading?: (query: QueryObserverLoadingResult) => JSX.Element; - idle?: (query: QueryObserverIdleResult) => JSX.Element; + ) => JSXElementOrNull; + loading?: (query: QueryObserverLoadingResult) => JSXElementOrNull; + idle?: (query: QueryObserverIdleResult) => JSXElementOrNull; } interface QueryCellOptionsNoEmpty extends QueryCellOptionsBase { - success: (query: QueryObserverSuccessResult) => JSX.Element; + success: (query: QueryObserverSuccessResult) => JSXElementOrNull; } interface QueryCellOptionsWithEmpty extends QueryCellOptionsBase { - success: (query: QueryObserverSuccessResult, TError>) => JSX.Element; + success: (query: QueryObserverSuccessResult, TError>) => JSXElementOrNull; /** * If there's no data (`null`, `undefined`, or `[]`), render this component */ - empty: (query: QueryObserverSuccessResult) => JSX.Element; + empty: (query: QueryObserverSuccessResult) => JSXElementOrNull; } export function QueryCell( opts: QueryCellOptionsWithEmpty -): JSX.Element; +): JSXElementOrNull; export function QueryCell( opts: QueryCellOptionsNoEmpty -): JSX.Element; +): JSXElementOrNull; export function QueryCell( opts: QueryCellOptionsNoEmpty | QueryCellOptionsWithEmpty ) { diff --git a/lib/webhooks/constants.ts b/lib/webhooks/constants.ts index b774d51d..ddd5a445 100644 --- a/lib/webhooks/constants.ts +++ b/lib/webhooks/constants.ts @@ -5,4 +5,4 @@ export const WEBHOOK_TRIGGER_EVENTS = [ WebhookTriggerEvents.BOOKING_CANCELLED, WebhookTriggerEvents.BOOKING_CREATED, WebhookTriggerEvents.BOOKING_RESCHEDULED, -] as const; +] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"]; diff --git a/package.json b/package.json index d691f723..b5ee1fd1 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "@heroicons/react": "^1.0.4", "@hookform/resolvers": "^2.8.1", "@jitsu/sdk-js": "^2.2.4", - "@prisma/client": "^2.30.2", "@next/bundle-analyzer": "11.1.2", + "@prisma/client": "^2.30.2", "@radix-ui/react-avatar": "^0.1.0", "@radix-ui/react-collapsible": "^0.1.0", "@radix-ui/react-dialog": "^0.1.0", @@ -75,8 +75,8 @@ "nodemailer": "^6.6.3", "otplib": "^12.0.1", "qrcode": "^1.4.4", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", "react-easy-crop": "^3.5.2", "react-hook-form": "^7.17.5", "react-hot-toast": "^2.1.0", diff --git a/pages/getting-started.tsx b/pages/getting-started.tsx index 9a2f1b38..0c73963e 100644 --- a/pages/getting-started.tsx +++ b/pages/getting-started.tsx @@ -19,11 +19,9 @@ import getIntegrations from "@lib/integrations/getIntegrations"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; +import { ClientSuspense } from "@components/ClientSuspense"; import Loader from "@components/Loader"; -import { ShellSubHeading } from "@components/Shell"; -import CalendarsList from "@components/integrations/CalendarsList"; -import ConnectedCalendarsList from "@components/integrations/ConnectedCalendarsList"; -import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections"; +import { CalendarListContainer } from "@components/integrations/CalendarListContainer"; import { Alert } from "@components/ui/Alert"; import Button from "@components/ui/Button"; import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule"; @@ -41,10 +39,6 @@ export default function Onboarding(props: inferSSRProps { - router.replace(router.asPath); - }; - const DEFAULT_EVENT_TYPES = [ { title: t("15min_meeting"), @@ -123,12 +117,9 @@ export default function Onboarding(props: inferSSRProps(null); /** End Name */ /** TimeZone */ - const [selectedTimeZone, setSelectedTimeZone] = useState({ - value: props.user.timeZone ?? dayjs.tz.guess(), - label: null, - }); + const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone ?? dayjs.tz.guess()); const currentTime = React.useMemo(() => { - return dayjs().tz(selectedTimeZone.value).format("H:mm A"); + return dayjs().tz(selectedTimeZone).format("H:mm A"); }, [selectedTimeZone]); /** End TimeZone */ @@ -269,7 +260,9 @@ export default function Onboarding(props: inferSSRProps { + setSelectedTimeZone(value); + }} className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" /> @@ -285,7 +278,7 @@ export default function Onboarding(props: inferSSRProps - {props.connectedCalendars.length > 0 && ( - <> - { - refreshData(); - }} - /> - } - /> - > - )} - { - refreshData(); - }} - /> - > + }> + + ), hideConfirm: true, confirmText: t("continue"), diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index f825e3c0..0cb11927 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -19,15 +19,16 @@ import showToast from "@lib/notification"; import { inferQueryOutput, trpc } from "@lib/trpc"; import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants"; +import { ClientSuspense } from "@components/ClientSuspense"; import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog"; import { List, ListItem, ListItemText, ListItemTitle } from "@components/List"; +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 CalendarsList from "@components/integrations/CalendarsList"; +import { CalendarListContainer } from "@components/integrations/CalendarListContainer"; import ConnectIntegration from "@components/integrations/ConnectIntegrations"; -import ConnectedCalendarsList from "@components/integrations/ConnectedCalendarsList"; import DisconnectIntegration from "@components/integrations/DisconnectIntegration"; import IntegrationListItem from "@components/integrations/IntegrationListItem"; import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections"; @@ -35,15 +36,14 @@ import { Alert } from "@components/ui/Alert"; import Button from "@components/ui/Button"; import Switch from "@components/ui/Switch"; -type TIntegrations = inferQueryOutput<"viewer.integrations">; -type TWebhook = TIntegrations["webhooks"][number]; +type TWebhook = inferQueryOutput<"viewer.webhook.list">[number]; function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) { const { t } = useLocale(); const utils = trpc.useContext(); const deleteWebhook = trpc.useMutation("viewer.webhook.delete", { async onSuccess() { - await utils.invalidateQueries(["viewer.integrations"]); + await utils.invalidateQueries(["viewer.webhhook.list"]); }, }); @@ -195,11 +195,11 @@ function WebhookDialogForm(props: { .handleSubmit(async (values) => { if (values.id) { await utils.client.mutation("viewer.webhook.edit", values); - await utils.invalidateQueries(["viewer.integrations"]); + await utils.invalidateQueries(["viewer.webhook.list"]); showToast(t("webhook_updated_successfully"), "success"); } else { await utils.client.mutation("viewer.webhook.create", values); - await utils.invalidateQueries(["viewer.integrations"]); + await utils.invalidateQueries(["viewer.webhook.list"]); showToast(t("webhook_created_successfully"), "success"); } @@ -269,8 +269,81 @@ function WebhookDialogForm(props: { ); } -function WebhookEmbed(props: { webhooks: TWebhook[] }) { +function WebhookListContainer() { const { t } = useLocale(); + const query = trpc.useQuery(["viewer.webhook.list"], { suspense: true }); + + const [newWebhookModal, setNewWebhookModal] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + return ( + ( + <> + + + + + + + Webhooks + Automation + + + setNewWebhookModal(true)} + data-testid="new_webhook"> + {t("new_webhook")} + + + + + + + {data.length ? ( + + {data.map((item) => ( + { + setEditing(item); + setEditModalOpen(true); + }} + /> + ))} + + ) : null} + + {/* New webhook dialog */} + !isOpen && setNewWebhookModal(false)}> + + setNewWebhookModal(false)} /> + + + {/* Edit webhook dialog */} + !isOpen && setEditModalOpen(false)}> + + {editing && ( + setEditModalOpen(false)} + defaultValues={editing} + /> + )} + + + > + )} + /> + ); +} + +function IframeEmbedContainer() { + const { t } = useLocale(); + // doesn't need suspense as it should already be loaded const user = trpc.useQuery(["viewer.me"]).data; const iframeTemplate = ``; @@ -278,57 +351,9 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) { "schedule_a_meeting" )}${iframeTemplate}