Files
calendar/packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsPlatformWrapper.tsx
Rajiv SahalandGitHub 63740c02c7 feat: calendar view atom v1 (#23840)
* fix: refactor

* add `isMonthViewProp` to header

* feat: init v1 for calendar view atom

* test breaking toggle buttons

* fix: make sure week start is always sunday for calendar view atom

* fixup

* fix: remove extra comments

* fix: add calendar view page in examples app

* chore: add changesets

* fix: coderabbit feedback

* fixup
2025-09-22 12:17:33 +00:00

449 lines
16 KiB
TypeScript

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 cn from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { AppListCard } from "@calcom/ui/components/app-list-card";
import type { ButtonProps } from "@calcom/ui/components/button";
import { Button } from "@calcom/ui/components/button";
import { CalendarSwitchComponent } from "@calcom/ui/components/calendar-switch";
import { DisconnectIntegrationComponent } from "@calcom/ui/components/disconnect-calendar-integration";
import { Dropdown, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/components/dropdown";
import { Switch } from "@calcom/ui/components/form";
import { List } from "@calcom/ui/components/list";
import * as Connect from "../../connect";
import { AppleConnect } from "../../connect/apple/AppleConnect";
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 { useToast } from "../../src/components/ui/use-toast";
import { SelectedCalendarsSettings } from "../SelectedCalendarsSettings";
export type CalendarRedirectUrls = {
google?: string;
outlook?: string;
};
export type SelectedCalendarsClassNames = {
container?: string;
header?: {
container?: string;
title?: string;
description?: string;
};
selectedCalendarsListClassNames?: {
container?: string;
selectedCalendar?: {
container?: string;
header?: {
container?: string;
title?: string;
description?: string;
};
body?: {
container?: string;
description?: string;
};
};
};
noSelectedCalendarsMessage?: string;
};
type SelectedCalendarsSettingsPlatformWrapperProps = {
classNames?: string;
classNamesObject?: SelectedCalendarsClassNames;
calendarRedirectUrls?: CalendarRedirectUrls;
allowDelete?: boolean;
isDryRun?: boolean;
};
export const SelectedCalendarsSettingsPlatformWrapper = ({
classNames = "mx-5 mb-6",
calendarRedirectUrls,
allowDelete,
isDryRun,
classNamesObject,
}: SelectedCalendarsSettingsPlatformWrapperProps) => {
const { t } = useLocale();
const query = useConnectedCalendars({});
return (
<AtomsWrapper>
<div>
<QueryCell
query={query}
success={({ data }) => {
const destinationCalendarId = data.destinationCalendar.externalId;
if (!data.connectedCalendars.length) {
return (
<SelectedCalendarsSettings classNames={classNamesObject?.container || classNames}>
<SelectedCalendarsSettingsHeading
classNames={classNamesObject?.header}
calendarRedirectUrls={calendarRedirectUrls}
isDryRun={isDryRun}
/>
<h1
className={cn(
"px-6 py-4 text-base leading-5",
classNamesObject?.noSelectedCalendarsMessage
)}>
No connected calendars found.
</h1>
</SelectedCalendarsSettings>
);
}
return (
<SelectedCalendarsSettings classNames={classNamesObject?.container || classNames}>
<SelectedCalendarsSettingsHeading
classNames={classNamesObject?.header}
calendarRedirectUrls={calendarRedirectUrls}
isDryRun={isDryRun}
/>
<List
noBorderTreatment
className={classNamesObject?.selectedCalendarsListClassNames?.container || "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
}
classNameObject={{
container: cn(
"border-subtle mt-4 rounded-lg border",
classNamesObject?.selectedCalendarsListClassNames?.selectedCalendar?.container
),
title:
classNamesObject?.selectedCalendarsListClassNames?.selectedCalendar?.header
?.title,
description:
classNamesObject?.selectedCalendarsListClassNames?.selectedCalendar?.header
?.description,
}}
actions={
<div className="flex w-32 justify-end">
{allowDelete && !connectedCalendar.delegationCredentialId && (
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
isDryRun={isDryRun}
/>
)}
</div>
}>
<div
className={cn(
"border-subtle border-t",
classNamesObject?.selectedCalendarsListClassNames?.selectedCalendar?.body
?.container
)}>
<p
className={cn(
"text-subtle px-5 pt-4 text-sm",
classNamesObject?.selectedCalendarsListClassNames?.selectedCalendar?.body
?.description
)}>
{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}
delegationCredentialId={connectedCalendar.delegationCredentialId}
eventTypeId={null}
isDryRun={isDryRun}
/>
);
})}
</ul>
</div>
</AppListCard>
);
}
return (
<Alert
key={`alert-${connectedCalendar.credentialId}`}
severity="warning"
title={t("something_went_wrong")}
message={<span>{connectedCalendar.error?.message || t("calendar_error")}</span>}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
!Boolean(connectedCalendar.delegationCredentialId) && (
<div className="flex w-32 justify-end">
<PlatformDisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
buttonProps={{ className: "border border-default" }}
slug={connectedCalendar.integration.slug}
isDryRun={isDryRun}
/>
</div>
)
}
/>
);
})}
</List>
</SelectedCalendarsSettings>
);
}}
/>
</div>
</AtomsWrapper>
);
};
const SelectedCalendarsSettingsHeading = ({
calendarRedirectUrls,
isDryRun,
classNames,
}: {
calendarRedirectUrls?: CalendarRedirectUrls;
isDryRun?: boolean;
classNames?: {
container?: string;
title?: string;
description?: string;
};
}) => {
const { t } = useLocale();
return (
<div className={cn("border-subtle border-b p-6", classNames?.container)}>
<div className="flex items-center justify-between">
<div>
<h4 className={cn("text-emphasis text-base font-semibold leading-5", classNames?.title)}>
{t("check_for_conflicts")}
</h4>
<p className={cn("text-default text-sm leading-tight", classNames?.description)}>
{t("select_calendars")}
</p>
</div>
<div className="flex flex-col xl:flex-row xl:space-x-5">
<div className="flex items-center">
<PlatformAdditionalCalendarSelector
calendarRedirectUrls={calendarRedirectUrls}
isDryRun={isDryRun}
/>
</div>
</div>
</div>
</div>
);
};
const PlatformDisconnectIntegration = (props: {
credentialId: number;
label?: string;
slug?: string;
trashIcon?: boolean;
isGlobal?: boolean;
onSuccess?: () => void;
buttonProps?: ButtonProps;
isDryRun?: boolean;
}) => {
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 () => {
!props.isDryRun && setModalOpen(false);
if (props.isDryRun) {
toast({
description: t("app_removed_successfully"),
});
}
if (!props.isDryRun && 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 & { isDryRun?: boolean }) => {
const { isChecked, title, credentialId, type, externalId, delegationCredentialId } = 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, delegationCredentialId });
} else {
await removeSelectedCalendar({
credentialId,
integration,
externalId,
delegationCredentialId,
});
}
};
return (
<CalendarSwitchComponent
destination={props.destination}
{...props}
isChecked={checkedInternal}
isLoading={isAddingSelectedCalendar || isRemovingSelectedCalendar}>
<Switch
checked={checkedInternal}
id={externalId}
onCheckedChange={async () => {
setCheckedInternal((prevValue) => !prevValue);
if (!props.isDryRun) {
await toggleSelectedCalendars({
isOn: !checkedInternal,
credentialId,
externalId,
integration: type,
});
}
}}
/>
</CalendarSwitchComponent>
);
};
const PlatformAdditionalCalendarSelector = ({
calendarRedirectUrls,
isDryRun,
}: {
calendarRedirectUrls?: CalendarRedirectUrls;
isDryRun?: boolean;
}) => {
const { t } = useLocale();
const { refetch } = useConnectedCalendars({});
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon="plus" color="secondary" className="md:rounded-md">
{t("add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto">
<div>
<div>
<Connect.GoogleCalendar
isDryRun={isDryRun}
isMultiCalendar={true}
isClickable={true}
tooltip={<></>}
redir={calendarRedirectUrls?.google ?? window.location.href}
label={t("add_calendar_label", { calendar: "Google" })}
loadingLabel={t("add_calendar_label", { calendar: "Google" })}
alreadyConnectedLabel={t("add_calendar_label", { calendar: "Google" })}
className="hover:bg-subtle hover:text-default cursor-pointer border-none bg-inherit text-inherit md:rounded-md"
/>
</div>
<div>
<Connect.OutlookCalendar
isDryRun={isDryRun}
isMultiCalendar={true}
isClickable={true}
tooltip={<></>}
redir={calendarRedirectUrls?.outlook ?? window.location.href}
label={t("add_calendar_label", { calendar: "Outlook" })}
loadingLabel={t("add_calendar_label", { calendar: "Outlook" })}
alreadyConnectedLabel={t("add_calendar_label", { calendar: "Outlook" })}
className="hover:bg-subtle hover:text-default cursor-pointer border-none bg-inherit text-inherit md:rounded-md"
/>
</div>
<div>
<AppleConnect
isDryRun={isDryRun}
onSuccess={refetch}
isClickable={true}
isMultiCalendar={true}
tooltip={<></>}
label={t("add_calendar_label", { calendar: "Apple" })}
loadingLabel={t("add_calendar_label", { calendar: "Apple" })}
alreadyConnectedLabel={t("add_calendar_label", { calendar: "Apple" })}
className="hover:bg-subtle hover:text-default cursor-pointer border-none bg-inherit text-inherit md:rounded-md"
/>
</div>
</div>
</DropdownMenuContent>
</Dropdown>
);
};