mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* 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
449 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|