feat: CalendarSettings atom (#16120)

* dumb components for calendar settings

* shadcn switch

* update exports

* update packages

* add calendar settings atom to examples app

* init calendar settings atom

* refactors

* fix import path

* export type for calendar switch props

* invalidate queries on deleting calendar credentials

* replace calendars list with calendar settings wrapper

* cleanup

* update styling

* refactors

* cleanup

* fix: missing key prop CalendarSettingsPlatformWrapper

* Label as client components

* Address client component build errors

* Move QueryCell out of packages/lib

* PR feedback

* more feedback

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-08-13 21:51:29 +05:30
1 parent b09b32b121
commit 0b03bcb90e
54 files changed
+781 -258

No files matched your search

+6 -69
View File
@@ -1,15 +1,14 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import type { CredentialOwner } from "@calcom/app-store/types";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Avatar, Badge, Icon, ListItemText } from "@calcom/ui";
import { AppListCard as AppListCardComponent } from "@calcom/ui";
type ShouldHighlight =
| {
@@ -21,7 +20,7 @@ type ShouldHighlight =
slug?: never;
};
type AppListCardProps = {
export type AppListCardProps = {
logo?: string;
title: string;
description: string;
@@ -37,21 +36,7 @@ type AppListCardProps = {
const schema = z.object({ hl: z.string().optional() });
export default function AppListCard(props: AppListCardProps) {
const { t } = useLocale();
const {
logo,
title,
description,
actions,
isDefault,
slug,
shouldHighlight,
isTemplate,
invalidCredential,
children,
credentialOwner,
className,
} = props;
const { slug, shouldHighlight } = props;
const {
data: { hl },
} = useTypedQuery(schema);
@@ -83,53 +68,5 @@ export default function AppListCard(props: AppListCardProps) {
};
}, [highlight, pathname, router, searchParams, shouldHighlight]);
return (
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100", className)}>
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
{logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
src={logo}
alt={`${title} logo`}
/>
) : null}
<div className="flex grow flex-col gap-y-1 truncate">
<div className="flex items-center gap-x-2">
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
<div className="flex items-center gap-x-2">
{isDefault && <Badge variant="green">{t("default")}</Badge>}
{isTemplate && <Badge variant="red">Template</Badge>}
</div>
</div>
<ListItemText component="p">{description}</ListItemText>
{invalidCredential && (
<div className="flex gap-x-2 pt-2">
<Icon name="circle-alert" className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
</ListItemText>
</div>
)}
</div>
{credentialOwner && (
<div>
<Badge variant="gray">
<div className="flex items-center">
<Avatar
className="mr-2"
alt={credentialOwner.name || "Nameless"}
size="xs"
imageSrc={getPlaceholderAvatar(credentialOwner.avatar, credentialOwner?.name as string)}
/>
{credentialOwner.name}
</div>
</Badge>
</div>
)}
{actions}
</div>
{children && <div className="w-full">{children}</div>}
</div>
);
return <AppListCardComponent {...props} />;
}
@@ -2,6 +2,7 @@ import type { FunctionComponent, SVGProps } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import { trpc } from "@calcom/trpc/react";
import {
Button,
@@ -12,8 +13,6 @@ import {
DropdownMenuTrigger,
} from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
interface AdditionalCalendarSelectorProps {
isPending?: boolean;
}
@@ -1,14 +1,11 @@
import Link from "next/link";
import { Fragment, useEffect } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import { CalendarSettingsWebWrapper } from "@calcom/atoms/monorepo";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Alert,
Button,
EmptyScreen,
Label,
@@ -22,7 +19,6 @@ import { QueryCell } from "@lib/QueryCell";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import AppListCard from "@components/AppListCard";
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
type Props = {
@@ -68,118 +64,6 @@ function CalendarList(props: Props) {
);
}
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
function ConnectedCalendarsList(props: Props) {
const { t } = useLocale();
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
suspense: true,
refetchOnWindowFocus: false,
});
const { fromOnboarding, isPending } = props;
return (
<QueryCell
query={query}
empty={() => null}
success={({ data }) => {
if (!data.connectedCalendars.length) {
return null;
}
return (
<div className="border-subtle mt-6 rounded-lg border">
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
<div>
<h4 className="text-emphasis text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
<div className="flex flex-col xl:flex-row xl:space-x-5">
{!!data.connectedCalendars.length && (
<div className="flex items-center">
<AdditionalCalendarSelector isPending={isPending} />
</div>
)}
</div>
</div>
</div>
<List noBorderTreatment className="p-6 pt-2">
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<AppListCard
shouldHighlight
slug={item.integration.slug}
title={item.integration.name}
logo={item.integration.logo}
description={item.primary?.email ?? item.integration.description}
className="border-subtle mt-4 rounded-lg border"
actions={
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={item.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
}>
<div className="border-subtle border-t">
{!fromOnboarding && (
<>
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-4 px-5 py-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={item.integration.type}
isChecked={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
credentialId={cal.credentialId}
/>
))}
</ul>
</>
)}
</div>
</AppListCard>
) : (
<Alert
severity="warning"
title={t("something_went_wrong")}
message={
<span>
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
{t("calendar_error")}
</span>
}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
<div className="flex w-32 justify-end md:pr-1">
<DisconnectIntegration
credentialId={item.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
}
/>
)}
</Fragment>
))}
</List>
</div>
);
}}
/>
);
}
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
const { t } = useLocale();
const { heading = true, fromOnboarding } = props;
@@ -245,7 +129,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
</div>
</div>
</div>
<ConnectedCalendarsList
<CalendarSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
+2
View File
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
@@ -1,3 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
@@ -1,18 +1,13 @@
"use client";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { ButtonProps } from "@calcom/ui";
import { Button, ConfirmationDialogContent, Dialog, DialogTrigger, showToast } from "@calcom/ui";
import { DisconnectIntegrationComponent, showToast } from "@calcom/ui";
export default function DisconnectIntegration({
credentialId,
label,
trashIcon,
isGlobal,
onSuccess,
buttonProps,
}: {
export default function DisconnectIntegration(props: {
credentialId: number;
label?: string;
trashIcon?: boolean;
@@ -21,6 +16,7 @@ export default function DisconnectIntegration({
buttonProps?: ButtonProps;
}) {
const { t } = useLocale();
const { onSuccess, credentialId } = props;
const [modalOpen, setModalOpen] = useState(false);
const utils = trpc.useUtils();
@@ -41,29 +37,11 @@ export default function DisconnectIntegration({
});
return (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogTrigger asChild>
<Button
color={buttonProps?.color || "destructive"}
StartIcon={!trashIcon ? undefined : "trash"}
size="base"
variant={trashIcon && !label ? "icon" : "button"}
disabled={isGlobal}
{...buttonProps}>
{label && label}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={() => {
mutation.mutate({ id: credentialId });
}}>
<p className="mt-5">{t("are_you_sure_you_want_to_remove_this_app")}</p>
</ConfirmationDialogContent>
</Dialog>
</>
<DisconnectIntegrationComponent
onDeletionConfirmation={() => mutation.mutate({ id: credentialId })}
isModalOpen={modalOpen}
onModalOpen={() => setModalOpen((prevValue) => !prevValue)}
{...props}
/>
);
}
@@ -1,3 +1,5 @@
"use client";
import { useEffect } from "react";
import { create } from "zustand";
@@ -1,3 +1,5 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
@@ -6,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon, showToast, Switch } from "@calcom/ui";
interface ICalendarSwitchProps {
export type ICalendarSwitchProps = {
title: string;
externalId: string;
type: string;
@@ -15,7 +17,7 @@ interface ICalendarSwitchProps {
isLastItemInList?: boolean;
destination?: boolean;
credentialId: number;
}
};
const CalendarSwitch = (props: ICalendarSwitchProps) => {
const { title, externalId, type, isChecked, name, isLastItemInList = false, credentialId } = props;
const [checkedInternal, setCheckedInternal] = useState(isChecked);
@@ -1,3 +1,5 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
@@ -1,3 +1,5 @@
"use client";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
@@ -1,3 +1,5 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, type ReactNode } from "react";
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export const CalendarSettings = (props: { children: ReactNode }) => {
return <div className="border-subtle mt-6 rounded-lg border">{props.children}</div>;
};
@@ -0,0 +1 @@
export { CalendarSettingsPlatformWrapper } from "./wrappers/CalendarSettingsPlatformWrapper";
@@ -0,0 +1,242 @@
import { useState } from "react";
import type { ICalendarSwitchProps } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { CALENDARS } from "@calcom/platform-constants";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import type { ButtonProps } from "@calcom/ui";
import {
CalendarSwitchComponent,
AppListCard,
List,
DisconnectIntegrationComponent,
Alert,
} from "@calcom/ui";
import { useAddSelectedCalendar } from "../../hooks/calendars/useAddSelectedCalendar";
import { useDeleteCalendarCredentials } from "../../hooks/calendars/useDeleteCalendarCredentials";
import { useRemoveSelectedCalendar } from "../../hooks/calendars/useRemoveSelectedCalendar";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { Switch } from "../../src/components/ui/switch";
import { useToast } from "../../src/components/ui/use-toast";
import { CalendarSettings } from "../CalendarSettings";
export const CalendarSettingsPlatformWrapper = () => {
const { t } = useLocale();
const query = useConnectedCalendars({});
return (
<AtomsWrapper>
<div>
<QueryCell
query={query}
success={({ data }) => {
const destinationCalendarId = data.destinationCalendar.externalId;
if (!data.connectedCalendars.length) {
return null;
}
return (
<CalendarSettings>
<CalendarSettingsHeading />
<List noBorderTreatment className="p-6 pt-2">
{data.connectedCalendars.map((connectedCalendar) => {
if (!!connectedCalendar.calendars && connectedCalendar.calendars.length > 0) {
return (
<AppListCard
key={`list-${connectedCalendar.credentialId}-${connectedCalendar.integration.slug}`}
shouldHighlight
slug={connectedCalendar.integration.slug}
title={connectedCalendar.integration.name}
logo={`https://app.cal.com${connectedCalendar.integration.logo}`}
description={
connectedCalendar.primary?.email ?? connectedCalendar.integration.description
}
className="border-subtle mt-4 rounded-lg border"
actions={
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
/>
</div>
}>
<div className="border-subtle border-t">
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-4 px-5 py-4">
{connectedCalendar.calendars?.map((cal) => {
return (
<PlatformCalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={connectedCalendar.integration.type}
isChecked={cal.isSelected}
destination={cal.externalId === destinationCalendarId}
credentialId={cal.credentialId}
/>
);
})}
</ul>
</div>
</AppListCard>
);
}
return (
<Alert
key={`alert-${connectedCalendar.credentialId}`}
severity="warning"
title={t("something_went_wrong")}
message={<span>{t("calendar_error")}</span>}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
/>
</div>
}
/>
);
})}
</List>
</CalendarSettings>
);
}}
/>
</div>
</AtomsWrapper>
);
};
const CalendarSettingsHeading = () => {
const { t } = useLocale();
return (
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
<div>
<h4 className="text-emphasis text-base font-semibold leading-5">{t("check_for_conflicts")}</h4>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
</div>
</div>
);
};
const PlatformDisconnectIntegration = (props: {
credentialId: number;
label?: string;
slug?: string;
trashIcon?: boolean;
isGlobal?: boolean;
onSuccess?: () => void;
buttonProps?: ButtonProps;
}) => {
const { t } = useLocale();
const { onSuccess, credentialId, slug } = props;
const [modalOpen, setModalOpen] = useState(false);
const { toast } = useToast();
const { mutate: deleteCalendarCredentials } = useDeleteCalendarCredentials({
onSuccess: () => {
toast({
description: t("app_removed_successfully"),
});
setModalOpen(false);
onSuccess && onSuccess();
},
onError: () => {
toast({
description: t("error_removing_app"),
});
setModalOpen(false);
},
});
return (
<DisconnectIntegrationComponent
onDeletionConfirmation={async () => {
slug &&
(await deleteCalendarCredentials({
calendar: slug.split("-")[0] as unknown as (typeof CALENDARS)[number],
id: credentialId,
}));
}}
{...props}
isModalOpen={modalOpen}
onModalOpen={() => setModalOpen((prevValue) => !prevValue)}
/>
);
};
const PlatformCalendarSwitch = (props: ICalendarSwitchProps) => {
const { isChecked, title, credentialId, type, externalId } = props;
const [checkedInternal, setCheckedInternal] = useState(isChecked);
const { toast } = useToast();
const { mutate: addSelectedCalendar, isPending: isAddingSelectedCalendar } = useAddSelectedCalendar({
onError: (err) => {
toast({
description: `Something went wrong while adding calendar - ${title}. ${err}`,
});
},
});
const { mutate: removeSelectedCalendar, isPending: isRemovingSelectedCalendar } = useRemoveSelectedCalendar(
{
onError: (err) => {
toast({
description: `Something went wrong while removing calendar - ${title}. ${err}`,
});
},
}
);
const toggleSelectedCalendars = async ({
isOn,
credentialId,
integration,
externalId,
}: {
isOn: boolean;
credentialId: number;
integration: string;
externalId: string;
}) => {
if (isOn) {
await addSelectedCalendar({ credentialId, integration, externalId });
} else {
await removeSelectedCalendar({ credentialId, integration, externalId });
}
};
return (
<CalendarSwitchComponent
destination={props.destination}
{...props}
isChecked={checkedInternal}
isLoading={isAddingSelectedCalendar || isRemovingSelectedCalendar}>
<Switch
checked={checkedInternal}
id={externalId}
onCheckedChange={async () => {
setCheckedInternal((prevValue) => !prevValue);
await toggleSelectedCalendars({
isOn: !checkedInternal,
credentialId,
externalId,
integration: type,
});
}}
/>
</CalendarSwitchComponent>
);
};
@@ -0,0 +1,152 @@
import Link from "next/link";
import React from "react";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui";
import { List } from "@calcom/ui";
import AppListCard from "@calcom/web/components/AppListCard";
import AdditionalCalendarSelector from "@calcom/web/components/apps/AdditionalCalendarSelector";
import { CalendarSettings } from "../CalendarSettings";
type CalendarSettingsWebWrapperProps = {
onChanged: () => unknown | Promise<unknown>;
fromOnboarding?: boolean;
destinationCalendarId?: string;
isPending?: boolean;
};
export const CalendarSettingsWebWrapper = (props: CalendarSettingsWebWrapperProps) => {
const { t } = useLocale();
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
suspense: true,
refetchOnWindowFocus: false,
});
const { fromOnboarding, isPending } = props;
return (
<div>
<QueryCell
query={query}
success={({ data }) => {
if (!data.connectedCalendars.length) {
return null;
}
return (
<CalendarSettings>
<CalendarSettingsHeading
isConnectedCalendarsPresent={!!data.connectedCalendars.length}
isPending={isPending}
/>
<List noBorderTreatment className="p-6 pt-2">
{data.connectedCalendars.map((connectedCalendar) => {
if (!!connectedCalendar.calendars && connectedCalendar.calendars.length > 0) {
return (
<AppListCard
key={`list-${connectedCalendar.credentialId}`}
shouldHighlight
slug={connectedCalendar.integration.slug}
title={connectedCalendar.integration.name}
logo={connectedCalendar.integration.logo}
description={
connectedCalendar.primary?.email ?? connectedCalendar.integration.description
}
className="border-subtle mt-4 rounded-lg border"
actions={
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
}>
<div className="border-subtle border-t">
{!fromOnboarding && (
<>
<p className="text-subtle px-5 pt-4 text-sm">
{t("toggle_calendars_conflict")}
</p>
<ul className="space-y-4 px-5 py-4">
{connectedCalendar.calendars?.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={connectedCalendar.integration.type}
isChecked={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
credentialId={cal.credentialId}
/>
))}
</ul>
</>
)}
</div>
</AppListCard>
);
}
return (
<Alert
key={`alert-${connectedCalendar.credentialId}`}
severity="warning"
title={t("something_went_wrong")}
message={
<span>
<Link href={`/apps/${connectedCalendar.integration.slug}`}>
{connectedCalendar.integration.name}
</Link>
: {t("calendar_error")}
</span>
}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
}
/>
);
})}
</List>
</CalendarSettings>
);
}}
/>
</div>
);
};
const CalendarSettingsHeading = (props: { isConnectedCalendarsPresent: boolean; isPending?: boolean }) => {
const { t } = useLocale();
return (
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
<div>
<h4 className="text-emphasis text-base font-semibold leading-5">{t("check_for_conflicts")}</h4>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
<div className="flex flex-col xl:flex-row xl:space-x-5">
{props.isConnectedCalendarsPresent && (
<div className="flex items-center">
<AdditionalCalendarSelector isPending={props.isPending} />
</div>
)}
</div>
</div>
</div>
);
};
@@ -1,10 +1,11 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
import { QUERY_KEY } from "../useConnectedCalendars";
interface IUseDeleteCalendarCredentials {
onSuccess?: (res: ApiResponse) => void;
@@ -21,6 +22,7 @@ export const useDeleteCalendarCredentials = (
},
}
) => {
const queryClient = useQueryClient();
const deleteCalendarCredentials = useMutation<
ApiResponse<{
status: string;
@@ -56,6 +58,9 @@ export const useDeleteCalendarCredentials = (
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
return deleteCalendarCredentials;
@@ -1,3 +1,5 @@
"use client";
import { createContext, useContext } from "react";
import type { translationKeys, CalProviderLanguagesType } from "../cal-provider/CalProvider";
+1
View File
@@ -20,3 +20,4 @@ export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalenda
export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar";
export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";
export { useTeams } from "./hooks/teams/useTeams";
export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/wrappers/CalendarSettingsPlatformWrapper";
+1
View File
@@ -7,3 +7,4 @@ export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useHandleBookEvent } from "./hooks/useHandleBookEvent";
export * as Dialog from "./src/components/ui/dialog";
export { Timezone } from "./timezone";
export { CalendarSettingsWebWrapper } from "./calendar-settings/wrappers/CalendarSettingsWebWrapper";
+1
View File
@@ -57,6 +57,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.17.15",
"class-variance-authority": "^0.4.0",
@@ -0,0 +1,25 @@
import { cn } from "@/lib/utils";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"bg-background pointer-events-none block h-5 w-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
@@ -1,47 +1,24 @@
import { Navbar } from "@/components/Navbar";
import { Inter } from "next/font/google";
import { useConnectedCalendars } from "@calcom/atoms";
import { useConnectedCalendars, CalendarSettings } from "@calcom/atoms";
const inter = Inter({ subsets: ["latin"] });
export default function Calendars(props: { calUsername: string; calEmail: string }) {
const { isLoading, data: calendars } = useConnectedCalendars();
const { isLoading, data: calendars } = useConnectedCalendars({});
const connectedCalendars = calendars?.connectedCalendars ?? [];
const destinationCalendar = calendars?.destinationCalendar ?? {};
return (
<main className={`flex min-h-screen flex-col ${inter.className}`}>
<Navbar username={props.calUsername} />
<div className="p-4">
{!!connectedCalendars?.length ? (
<h1 className="my-4 text-lg font-bold">Your Connected Calendars</h1>
) : (
<h1 className="mx-10 my-4 text-xl font-bold">
You have not connected any calendars yet, please connect your Google calendar.
</h1>
)}
{isLoading ? (
<div>Loading...</div>
) : (
Boolean(connectedCalendars?.length) &&
connectedCalendars.map((connectedCalendar) => (
<div key={connectedCalendar.credentialId}>
<h1 className="text-md font-bold">{connectedCalendar.integration.name}</h1>
{connectedCalendar.calendars?.map((calendar) => (
<div key={calendar.id}>
<h2>{calendar.name}</h2>
</div>
))}
</div>
))
)}
{!!connectedCalendars?.length && <hr className="my-4" />}
{!isLoading && destinationCalendar.id && (
<div className="">
<h2 className="text-md font-bold">Destination Calendar: {destinationCalendar.name}</h2>
<p>{destinationCalendar.integrationTitle}</p>
</div>
)}
{!isLoading && !connectedCalendars?.length && (
<h1 className="mx-10 my-4 text-xl font-bold">
You have not connected any calendars yet, please connect your Google, Outlook or Apple calendar.
</h1>
)}
<div className="mx-10 my-4">
<CalendarSettings />
</div>
</main>
);
+80
View File
@@ -0,0 +1,80 @@
import type {
QueryObserverPendingResult,
QueryObserverRefetchErrorResult,
QueryObserverSuccessResult,
QueryObserverLoadingErrorResult,
UseQueryResult,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Loader } from "@calcom/ui";
type ErrorLike = {
message: string;
};
type JSXElementOrNull = JSX.Element | null;
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
query: UseQueryResult<TData, TError>;
customLoader?: ReactNode;
error?: (
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
) => JSXElementOrNull;
loading?: (query: QueryObserverPendingResult<TData, TError> | null) => JSXElementOrNull;
}
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
extends QueryCellOptionsBase<TData, TError> {
success: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull;
}
interface QueryCellOptionsWithEmpty<TData, TError extends ErrorLike>
extends QueryCellOptionsBase<TData, TError> {
success: (query: QueryObserverSuccessResult<NonNullable<TData>, TError>) => JSXElementOrNull;
/**
* If there's no data (`null`, `undefined`, or `[]`), render this component
*/
empty: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull;
}
export function QueryCell<TData, TError extends ErrorLike>(
opts: QueryCellOptionsWithEmpty<TData, TError>
): JSXElementOrNull;
export function QueryCell<TData, TError extends ErrorLike>(
opts: QueryCellOptionsNoEmpty<TData, TError>
): JSXElementOrNull;
/** @deprecated Use `trpc.useQuery` instead. */
export function QueryCell<TData, TError extends ErrorLike>(
opts: QueryCellOptionsNoEmpty<TData, TError> | QueryCellOptionsWithEmpty<TData, TError>
) {
const { query } = opts;
const { isLocaleReady } = useLocale();
const StatusLoader = opts.customLoader || <Loader />; // Fixes edge case where this can return null form query cell
if (!isLocaleReady) {
return opts.loading?.(query.status === "pending" ? query : null) ?? StatusLoader;
}
if (query.status === "pending") {
return opts.loading?.(query) ?? StatusLoader;
}
if (query.status === "success") {
if ("empty" in opts && (query.data == null || (Array.isArray(query.data) && query.data.length === 0))) {
return opts.empty(query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return opts.success(query as any);
}
if (query.status === "error") {
return (
opts.error?.(query) ?? (
<Alert severity="error" title="Something went wrong" message={query.error.message} />
)
);
}
// impossible state
return null;
}
@@ -0,0 +1,74 @@
"use client";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar, Badge, Icon, ListItemText } from "@calcom/ui";
import type { AppListCardProps } from "../../../../apps/web/components/AppListCard";
export const AppListCard = (props: AppListCardProps & { highlight?: boolean }) => {
const { t } = useLocale();
const {
logo,
title,
description,
actions,
isDefault,
isTemplate,
invalidCredential,
children,
credentialOwner,
className,
highlight,
} = props;
return (
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100", className)}>
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
{logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
src={logo}
alt={`${title} logo`}
/>
) : null}
<div className="flex grow flex-col gap-y-1 truncate">
<div className="flex items-center gap-x-2">
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
<div className="flex items-center gap-x-2">
{isDefault && <Badge variant="green">{t("default")}</Badge>}
{isTemplate && <Badge variant="red">Template</Badge>}
</div>
</div>
<ListItemText component="p">{description}</ListItemText>
{invalidCredential && (
<div className="flex gap-x-2 pt-2">
<Icon name="circle-alert" className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
</ListItemText>
</div>
)}
</div>
{credentialOwner && (
<div>
<Badge variant="gray">
<div className="flex items-center">
<Avatar
className="mr-2"
alt={credentialOwner.name || "Nameless"}
size="xs"
imageSrc={getPlaceholderAvatar(credentialOwner.avatar, credentialOwner?.name as string)}
/>
{credentialOwner.name}
</div>
</Badge>
</div>
)}
{actions}
</div>
<div className="w-full">{children}</div>
</div>
);
};
@@ -0,0 +1 @@
export { AppListCard } from "./AppListCard";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { AppCategories } from "@prisma/client";
import { usePathname, useRouter } from "next/navigation";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import type { Options } from "@glidejs/glide";
import Glide from "@glidejs/glide";
import "@glidejs/glide/dist/css/glide.core.min.css";
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Children, Fragment, useEffect, useState } from "react";
@@ -0,0 +1,43 @@
"use client";
import type { ReactNode } from "react";
import { type ICalendarSwitchProps } from "@calcom/features/calendars/CalendarSwitch";
import { classNames } from "@calcom/lib";
import { Icon } from "@calcom/ui";
export function CalendarSwitchComponent(
props: ICalendarSwitchProps & {
isLoading: boolean;
children: ReactNode;
translations?: {
spanText?: string;
};
}
) {
const {
externalId,
name,
isLoading,
translations = {
spanText: "Adding events to",
},
children,
} = props;
return (
<div className={classNames("my-2 flex flex-row items-center")}>
<div className="flex pl-2">{children}</div>
<label className="ml-3 text-sm font-medium leading-5" htmlFor={externalId}>
{name}
</label>
{!!props.destination && (
<span className="bg-subtle text-default ml-8 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-normal sm:ml-4">
<Icon name="arrow-left" className="h-4 w-4" />
{translations.spanText}
</span>
)}
{isLoading && <Icon name="rotate-cw" className="text-muted h-4 w-4 animate-spin ltr:ml-1 rtl:mr-1" />}
</div>
);
}
@@ -0,0 +1 @@
export { CalendarSwitchComponent } from "./CalendarSwitch";
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
@@ -1,3 +1,5 @@
"use client";
import type {
ColumnDef,
ColumnFiltersState,
+2
View File
@@ -1,3 +1,5 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { usePathname, useRouter } from "next/navigation";
import type { ForwardRefExoticComponent, ReactElement, ReactNode } from "react";
@@ -0,0 +1,48 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonProps } from "@calcom/ui";
import { Button, ConfirmationDialogContent, Dialog, DialogTrigger } from "@calcom/ui";
export const DisconnectIntegrationComponent = ({
label,
trashIcon,
isGlobal,
isModalOpen = false,
onModalOpen,
onDeletionConfirmation,
buttonProps,
}: {
label?: string;
trashIcon?: boolean;
isGlobal?: boolean;
isModalOpen: boolean;
onModalOpen: () => void;
onDeletionConfirmation: () => void;
buttonProps?: ButtonProps;
}) => {
const { t } = useLocale();
return (
<>
<Dialog open={isModalOpen} onOpenChange={onModalOpen}>
<DialogTrigger asChild>
<Button
color={buttonProps?.color || "destructive"}
StartIcon={!trashIcon ? undefined : "trash"}
size="base"
variant={trashIcon && !label ? "icon" : "button"}
disabled={isGlobal}
{...buttonProps}>
{label}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={onDeletionConfirmation}>
<p className="mt-5">{t("are_you_sure_you_want_to_remove_this_app")}</p>
</ConfirmationDialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1 @@
export { DisconnectIntegrationComponent } from "./DisconnectIntegration";
@@ -1,3 +1,5 @@
"use client";
import classNames from "classnames";
import { useState } from "react";
import type { ControllerRenderProps } from "react-hook-form";
@@ -1,3 +1,5 @@
"use client";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
LexicalTypeaheadMenuPlugin,
@@ -1,3 +1,5 @@
"use client";
import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import {
@@ -1,3 +1,5 @@
"use client";
import * as Popover from "@radix-ui/react-popover";
import { useState } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful";
@@ -1,3 +1,5 @@
"use client";
import type { ReactNode } from "react";
import React, { forwardRef, useCallback, useId, useState } from "react";
import { useFormContext } from "react-hook-form";
@@ -1,3 +1,5 @@
"use client";
import React, { forwardRef, useId, useState } from "react";
import classNames from "@calcom/lib/classNames";
@@ -1,3 +1,5 @@
"use client";
import { useMemo, useState } from "react";
import type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select";
import BaseSelect from "react-timezone-select";
@@ -1,3 +1,5 @@
"use client";
import { useId } from "@radix-ui/react-id";
import { Root as ToggleGroupPrimitive, Item as ToggleGroupItemPrimitive } from "@radix-ui/react-toggle-group";
import { useState } from "react";
@@ -1,3 +1,5 @@
"use client";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useRouter } from "next/navigation";
@@ -1,3 +1,5 @@
"use client";
import { useCallback, useState, useEffect } from "react";
import Cropper from "react-easy-crop";
@@ -1,3 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import Cropper from "react-easy-crop";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import Head from "next/head";
import { createContext, useContext, useState, useEffect } from "react";
import type { ReactNode } from "react";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type PropsWithChildren } from "react";
@@ -1,3 +1,5 @@
"use client";
import type { PropsWithChildren } from "react";
import React, { useRef, useEffect, useState } from "react";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { isSupportedCountry } from "libphonenumber-js";
import { useState, useEffect } from "react";
import PhoneInput from "react-phone-input-2";
+3
View File
@@ -140,6 +140,9 @@ export {
ConfirmationContent,
} from "./components/dialog";
export type { DialogProps, ConfirmationDialogContentProps } from "./components/dialog";
export { AppListCard } from "./components/app-list-card";
export { DisconnectIntegrationComponent } from "./components/disconnect-calendar-integration";
export { CalendarSwitchComponent } from "./components/calendar-switch";
export { showToast } from "./components/toast"; // We don't export the toast components as they are only used in local storybook file
export { Meta, MetaProvider, useMeta } from "./components/meta";
export { ShellSubHeading } from "./components/layout";
+2
View File
@@ -1,3 +1,5 @@
"use client";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { usePathname } from "next/navigation";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { useEffect } from "react";
type CssVariables = Record<string, string>;