refactor `/integrations` with `<Suspense />` (#1078)

* suspense

* iframe embeds

* calendar list container

* rename things as a container

* use list container on onboarding

* fix

* rm code

* newer alpha

* make it work in react 17

* fix

* fix

* make components handle error state through `QueryCell`

* fix constant

* fix type error

* type error

* type fixes

* fix package.lock

* fix webhook invalidate

* fix mt

* fix typo

* pr comment
pull/1078/merge
Alex Johansson 2021-10-30 16:54:21 +01:00 committed by GitHub
parent 78523f7a57
commit 1790aeb577
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 414 additions and 472 deletions

View File

@ -5,5 +5,9 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.run": "onSave"
"eslint.run": "onSave",
"workbench.colorCustomizations": {
"titleBar.activeBackground": "#888888",
"titleBar.inactiveBackground": "#292929"
}
}

View File

@ -0,0 +1,9 @@
import { Suspense, SuspenseProps } from "react";
/**
* Wrapper around `<Suspense />` which will render the `fallback` when on server
* Can be simply replaced by `<Suspense />` once React 18 is ready.
*/
export const ClientSuspense = (props: SuspenseProps) => {
return <>{typeof window !== "undefined" ? <Suspense {...props} /> : props.fallback}</>;
};

View File

@ -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<unknown>;
};
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 (
<div className="py-1">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
</div>
);
}
function ConnectedCalendarsList(props: Props) {
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
return (
<QueryCell
query={query}
empty={() => null}
success={({ data }) => (
<List>
{data.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<IntegrationListItem
{...item.integration}
description={item.primary?.externalId || "No external Id"}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
Disconnect
</Button>
)}
onOpenChange={props.onChanged}
/>
}>
<ul className="p-4 space-y-2">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId as string}
title={cal.name as string}
type={item.integration.type}
defaultSelected={cal.isSelected}
/>
))}
</ul>
</IntegrationListItem>
) : (
<Alert
severity="warning"
title="Something went wrong"
message={item.error?.message}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
Disconnect
</Button>
)}
onOpenChange={() => props.onChanged()}
/>
}
/>
)}
</Fragment>
))}
</List>
)}
/>
);
}
function CalendarList(props: Props) {
const query = trpc.useQuery(["viewer.integrations"]);
return (
<QueryCell
query={query}
success={({ data }) => (
<List>
{data.calendar.items.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={
<ConnectIntegration
type={item.type}
render={(btnProps) => (
<Button color="secondary" {...btnProps}>
Connect
</Button>
)}
onOpenChange={() => props.onChanged()}
/>
}
/>
))}
</List>
)}
/>
);
}
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 && (
<ShellSubHeading
className="mt-10"
title={<SubHeadingTitleWithConnections title="Calendars" numConnections={query.data?.length} />}
subtitle={
<>
Configure how your links integrate with your calendars.
<br />
You can override these settings on a per event basis.
</>
}
/>
)}
<ConnectedCalendarsList onChanged={onChanged} />
{!!query.data?.length && (
<ShellSubHeading
className="mt-6"
title={<SubHeadingTitleWithConnections title="Connect an additional calendar" />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
);
}

View File

@ -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 (
<div className="py-1">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
</div>
);
}

View File

@ -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<void>;
}
const CalendarsList = (props: Props): JSX.Element => {
const { calendars, onChanged } = props;
return (
<List>
{calendars.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={
<ConnectIntegration
type={item.type}
render={(btnProps) => (
<Button color="secondary" {...btnProps}>
Connect
</Button>
)}
onOpenChange={onChanged}
/>
}
/>
))}
</List>
);
};
export default CalendarsList;

View File

@ -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<void>;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const { type } = props;
const [isLoading, setIsLoading] = useState(false);

View File

@ -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<void>;
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 (
<List>
{connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<IntegrationListItem
{...item.integration}
description={item.primary?.externalId || "No external Id"}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
Disconnect
</Button>
)}
onOpenChange={onChanged}
/>
}>
<ul className="p-4 space-y-2">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name}
type={item.integration.type}
defaultSelected={cal.isSelected}
/>
))}
</ul>
</IntegrationListItem>
) : (
<Alert
severity="warning"
title="Something went wrong"
message={item.error?.message}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
Disconnect
</Button>
)}
onOpenChange={onChanged}
/>
}
/>
)}
</Fragment>
))}
</List>
);
};
export default ConnectedCalendarsList;

View File

@ -9,7 +9,7 @@ export default function DisconnectIntegration(props: {
/** Integration credential id */
id: number;
render: (renderProps: ButtonBaseProps) => JSX.Element;
onOpenChange: (isOpen: boolean) => void | Promise<void>;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation(

View File

@ -13,36 +13,37 @@ import { Alert } from "@components/ui/Alert";
type ErrorLike = {
message: string;
};
type JSXElementOrNull = JSX.Element | null;
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
query: UseQueryResult<TData, TError>;
error?: (
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
) => JSX.Element;
loading?: (query: QueryObserverLoadingResult<TData, TError>) => JSX.Element;
idle?: (query: QueryObserverIdleResult<TData, TError>) => JSX.Element;
) => JSXElementOrNull;
loading?: (query: QueryObserverLoadingResult<TData, TError>) => JSXElementOrNull;
idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull;
}
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
extends QueryCellOptionsBase<TData, TError> {
success: (query: QueryObserverSuccessResult<TData, TError>) => JSX.Element;
success: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull;
}
interface QueryCellOptionsWithEmpty<TData, TError extends ErrorLike>
extends QueryCellOptionsBase<TData, TError> {
success: (query: QueryObserverSuccessResult<NonNullable<TData>, TError>) => JSX.Element;
success: (query: QueryObserverSuccessResult<NonNullable<TData>, TError>) => JSXElementOrNull;
/**
* If there's no data (`null`, `undefined`, or `[]`), render this component
*/
empty: (query: QueryObserverSuccessResult<TData, TError>) => JSX.Element;
empty: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull;
}
export function QueryCell<TData, TError extends ErrorLike>(
opts: QueryCellOptionsWithEmpty<TData, TError>
): JSX.Element;
): JSXElementOrNull;
export function QueryCell<TData, TError extends ErrorLike>(
opts: QueryCellOptionsNoEmpty<TData, TError>
): JSX.Element;
): JSXElementOrNull;
export function QueryCell<TData, TError extends ErrorLike>(
opts: QueryCellOptionsNoEmpty<TData, TError> | QueryCellOptionsWithEmpty<TData, TError>
) {

View File

@ -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"];

View File

@ -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",

View File

@ -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<typeof getServerSideProp
const { t } = useLocale();
const router = useRouter();
const refreshData = () => {
router.replace(router.asPath);
};
const DEFAULT_EVENT_TYPES = [
{
title: t("15min_meeting"),
@ -123,12 +117,9 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
const bioRef = useRef<HTMLInputElement>(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<typeof getServerSideProp
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={setSelectedTimeZone}
onChange={({ value }) => {
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"
/>
</fieldset>
@ -285,7 +278,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
setSubmitting(true);
await updateUser({
name: nameRef.current?.value,
timeZone: selectedTimeZone.value,
timeZone: selectedTimeZone,
});
setEnteredName(nameRef.current?.value || "");
setSubmitting(true);
@ -300,28 +293,9 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
title: t("connect_your_calendar"),
description: t("connect_your_calendar_instructions"),
Component: (
<>
{props.connectedCalendars.length > 0 && (
<>
<ConnectedCalendarsList
connectedCalendars={props.connectedCalendars}
onChanged={() => {
refreshData();
}}
/>
<ShellSubHeading
className="mt-6"
title={<SubHeadingTitleWithConnections title="Connect an additional calendar" />}
/>
</>
)}
<CalendarsList
calendars={props.integrations}
onChanged={() => {
refreshData();
}}
/>
</>
<ClientSuspense fallback={<Loader />}>
<CalendarListContainer heading={false} />
</ClientSuspense>
),
hideConfirm: true,
confirmText: t("continue"),

View File

@ -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<TWebhook | null>(null);
return (
<QueryCell
query={query}
success={({ data }) => (
<>
<ShellSubHeading className="mt-10" title={t("Webhooks")} subtitle={t("receive_cal_meeting_data")} />
<List>
<ListItem className={classNames("flex-col")}>
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
<div className="flex-grow pl-2 truncate">
<ListItemTitle component="h3">Webhooks</ListItemTitle>
<ListItemText component="p">Automation</ListItemText>
</div>
<div>
<Button
color="secondary"
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>
</div>
</ListItem>
</List>
{data.length ? (
<List>
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm handleClose={() => setNewWebhookModal(false)} />
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
key={editing.id}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</>
)}
/>
);
}
function IframeEmbedContainer() {
const { t } = useLocale();
// doesn't need suspense as it should already be loaded
const user = trpc.useQuery(["viewer.me"]).data;
const iframeTemplate = `<iframe src="${process.env.NEXT_PUBLIC_BASE_URL}/${user?.username}" frameborder="0" allowfullscreen></iframe>`;
@ -278,57 +351,9 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) {
"schedule_a_meeting"
)}</title><style>body {margin: 0;}iframe {height: calc(100vh - 4px);width: calc(100vw - 4px);box-sizing: border-box;}</style></head><body>${iframeTemplate}</body></html>`;
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<>
<ShellSubHeading className="mt-10" title={t("Webhooks")} subtitle={t("receive_cal_meeting_data")} />
<List>
<ListItem className={classNames("flex-col")}>
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
<div className="flex-grow pl-2 truncate">
<ListItemTitle component="h3">Webhooks</ListItemTitle>
<ListItemText component="p">Automation</ListItemText>
</div>
<div>
<Button color="secondary" onClick={() => setNewWebhookModal(true)} data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>
</div>
</ListItem>
</List>
{props.webhooks.length ? (
<List>
{props.webhooks.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div>
{/* {!!props.webhooks.length && (
<WebhookList
webhooks={props.webhooks}
onChange={() => {}}
onEditWebhook={editWebhook}></WebhookList>
)} */}
</div>
</div>
</div>
<ShellSubHeading title={t("iframe_embed")} subtitle={t("embed_calcom")} />
<ShellSubHeading title={t("iframe_embed")} subtitle={t("embed_calcom")} className="mt-10" />
<div className="lg:pb-8 lg:col-span-9">
<List>
<ListItem className={classNames("flex-col")}>
@ -398,25 +423,6 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) {
{t("browse_api_documentation")}
</a>
</div>
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm handleClose={() => setNewWebhookModal(false)} />
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
key={editing.id}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</>
);
}
@ -474,89 +480,59 @@ function ConnectOrDisconnectIntegrationButton(props: {
);
}
export default function IntegrationsPage() {
const query = trpc.useQuery(["viewer.integrations"]);
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
};
function IntegrationsContainer() {
const query = trpc.useQuery(["viewer.integrations"], { suspense: true });
return (
<QueryCell
query={query}
success={({ data }) => (
<>
<ShellSubHeading
title={
<SubHeadingTitleWithConnections
title="Conferencing"
numConnections={data.conferencing.numActive}
/>
}
/>
<List>
{data.conferencing.items.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
/>
))}
</List>
<ShellSubHeading
className="mt-10"
title={<SubHeadingTitleWithConnections title="Payment" numConnections={data.payment.numActive} />}
/>
<List>
{data.payment.items.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
/>
))}
</List>
</>
)}></QueryCell>
);
}
export default function IntegrationsPage() {
return (
<Shell heading="Integrations" subtitle="Connect your favourite apps.">
<QueryCell
query={query}
success={({ data }) => {
return (
<>
<ShellSubHeading
title={
<SubHeadingTitleWithConnections
title="Conferencing"
numConnections={data.conferencing.numActive}
/>
}
/>
<List>
{data.conferencing.items.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
/>
))}
</List>
<ShellSubHeading
className="mt-10"
title={
<SubHeadingTitleWithConnections title="Payment" numConnections={data.payment.numActive} />
}
/>
<List>
{data.payment.items.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={<ConnectOrDisconnectIntegrationButton {...item} />}
/>
))}
</List>
<ShellSubHeading
className="mt-10"
title={
<SubHeadingTitleWithConnections
title="Calendars"
numConnections={data.calendar.numActive}
/>
}
subtitle={
<>
Configure how your links integrate with your calendars.
<br />
You can override these settings on a per event basis.
</>
}
/>
{data.connectedCalendars.length > 0 && (
<>
<ConnectedCalendarsList
connectedCalendars={data.connectedCalendars}
onChanged={handleOpenChange}
/>
<ShellSubHeading
className="mt-6"
title={<SubHeadingTitleWithConnections title="Connect an additional calendar" />}
/>
</>
)}
<CalendarsList calendars={data.calendar.items} onChanged={handleOpenChange} />
<WebhookEmbed webhooks={data.webhooks} />
</>
);
}}
/>
<ClientSuspense fallback={<Loader />}>
<IntegrationsContainer />
<CalendarListContainer />
<WebhookListContainer />
<IframeEmbedContainer />
</ClientSuspense>
</Shell>
);
}

View File

@ -313,6 +313,18 @@ const loggedInViewerRouter = createProtectedRouter()
};
},
})
.query("connectedCalendars", {
async resolve({ ctx }) {
const { user } = ctx;
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
// get all the connected integrations' calendars (from third party)
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
return connectedCalendars;
},
})
.query("integrations", {
async resolve({ ctx }) {
const { user } = ctx;
@ -338,11 +350,6 @@ const loggedInViewerRouter = createProtectedRouter()
// get all the connected integrations' calendars (from third party)
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const webhooks = await ctx.prisma.webhook.findMany({
where: {
userId: user.id,
},
});
return {
conferencing: {
items: conferencing,
@ -357,7 +364,6 @@ const loggedInViewerRouter = createProtectedRouter()
numActive: countActive(payment),
},
connectedCalendars,
webhooks,
};
},
})

View File

@ -38,7 +38,8 @@
"jest-playwright-preset",
"expect-playwright"
],
"allowJs": false
"allowJs": false,
"incremental": true
},
"include": [
"next-env.d.ts",

View File

@ -2611,11 +2611,6 @@ bowser@^2.8.1:
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
bowser@^2.8.1:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@ -3818,11 +3813,6 @@ fast-equals@^1.6.3:
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-1.6.3.tgz#84839a1ce20627c463e1892f2ae316380c81b459"
integrity sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ==
fast-equals@^1.6.3:
version "1.6.3"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-1.6.3.tgz#84839a1ce20627c463e1892f2ae316380c81b459"
integrity sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ==
fast-glob@^3.1.1, fast-glob@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
@ -4348,18 +4338,6 @@ history@^4.9.0:
tiny-warning "^1.0.0"
value-equal "^1.0.1"
history@^4.9.0:
version "4.10.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==
dependencies:
"@babel/runtime" "^7.1.2"
loose-envify "^1.2.0"
resolve-pathname "^3.0.0"
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
value-equal "^1.0.1"
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -6556,13 +6534,6 @@ path-to-regexp@^1.7.0:
dependencies:
isarray "0.0.1"
path-to-regexp@^1.7.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
dependencies:
isarray "0.0.1"
path-type@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
@ -6998,9 +6969,10 @@ react-date-picker@^8.3.3:
react-fit "^1.0.3"
update-input-width "^1.2.2"
react-dom@17.0.2:
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@ -7197,9 +7169,10 @@ react-use-intercom@1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/react-use-intercom/-/react-use-intercom-1.4.0.tgz#796527728c131ebf132186385bf78f69dbcd84cc"
react@17.0.2:
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@ -7332,11 +7305,6 @@ resolve-pathname@^3.0.0:
resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd"
integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==
resolve-pathname@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd"
integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==
resolve@^1.10.0, resolve@^1.20.0:
version "1.20.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
@ -7431,7 +7399,8 @@ saxes@^5.0.1:
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"