chore: Disables syncing of calendarList on overlay calendar fetch (#27020)

* chore: Disables syncing of calendarList on overlay calendar fetch

* Unwrap ToggledConnectedCalendars

* Pick the right type for connectedCalendars

* further type amends

* Type fixes, tons of em

* Some further fixups consistent with what was before

* fix: resolve type incompatibility in getConnectedDestinationCalendars return type

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: update DestinationCalendarProps to accept tRPC handler return type

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: use generic type for DestinationCalendarProps to accept tRPC enriched types

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: simplify DestinationCalendarProps type for better compatibility

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: export ConnectedCalendar type for consistent type inference

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: use permissive type for connectedCalendars to accept tRPC enriched types

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: export ConnectedCalendar and ConnectedDestinationCalendars types from platform-libraries

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: update ConnectedCalendar type to use boolean | null for primary field

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Update ConnectedCalendarItem

* Undo some of Devins fixes, more fixes

* Fixup the destination calendar return type, historically not null

* Change init to undefined to deal with null

* Approach to connect selected calendars with the right credential

* This return type is used way too much everywhere, not refactoring

* Add the selectedCalendars to the type

* Actually fix overlay calendar

* set calendarsToLoad param as required

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Add new translation for 'Calendar Settings'

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Alex van Andel
2026-01-28 14:20:19 -03:00
committed by GitHub
co-authored by cubic-dev-ai[bot] Devin AI
parent c62f333337
commit c9bd18e866
22 changed files with 486 additions and 428 deletions
@@ -50,7 +50,7 @@ export const DestinationCalendarSettingsWebWrapper = ({
};
const validatedReminderValue = reminderSchema.safeParse(
calendars.data.destinationCalendar.customCalendarReminder
calendars.data.destinationCalendar?.customCalendarReminder
);
let reminderValue: ReminderMinutes = null;
if (validatedReminderValue.success) {
@@ -63,7 +63,7 @@ export const DestinationCalendarSettingsWebWrapper = ({
connectedCalendars={calendars.data.connectedCalendars}
isPending={mutation.isPending}
destinationCalendar={calendars.data.destinationCalendar}
value={calendars.data.destinationCalendar.externalId}
value={calendars.data.destinationCalendar?.externalId}
hidePlaceholder
onChange={mutation.mutate}
onReminderChange={handleReminderChange}
@@ -6,7 +6,7 @@ interface IConnectedCalendarItem {
externalId?: string;
integrationType: string;
calendars?: {
primary: true | null;
primary: boolean | null;
isSelected: boolean;
credentialId: number;
name?: string | undefined;
@@ -18,7 +18,7 @@ interface IConnectedCalendarItem {
}[];
}
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
const ConnectedCalendarItem = (prop: IConnectedCalendarItem): JSX.Element => {
const { name, logo, externalId, calendars, integrationType } = prop;
return (
@@ -26,7 +26,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<div className="flex flex-row items-center p-4">
<img src={logo} alt={name} className="m-1 h-8 w-8" />
<div className="mx-4">
<p className="font-sans text-sm font-bold leading-5">
<p className="font-bold font-sans text-sm leading-5">
{name}
{/* Temporarily removed till we use it on another place */}
{/* <span className="mx-1 rounded-[4px] bg-cal-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
@@ -36,7 +36,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<div className="fle-row flex">
<span
title={externalId}
className="max-w-44 text-subtle mt-1 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm">
className="mt-1 max-w-44 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm text-subtle">
{externalId}{" "}
</span>
</div>
@@ -49,7 +49,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
{t("edit")}
</Button> */}
</div>
<div className="border-subtle h-px w-full border-b" />
<div className="h-px w-full border-subtle border-b" />
<div>
<ul className="p-4">
{calendars?.map((calendar, i) => (
+35 -45
View File
@@ -93,7 +93,7 @@ const BookerComponent = ({
roundRobinHideOrgAndTeam,
hideOrgTeamAvatar,
showNoAvailabilityDialog,
}: BookerProps & WrappedBookerProps) => {
}: BookerProps & WrappedBookerProps): JSX.Element | null => {
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
const [bookerState, setBookerState] = useBookerStoreContext(
@@ -180,13 +180,7 @@ const BookerComponent = ({
isVerificationCodeSending,
} = verifyEmail;
const {
overlayBusyDates,
isOverlayCalendarEnabled,
connectedCalendars,
loadingConnectedCalendar,
onToggleCalendar,
} = calendars;
const { overlayBusyDates, isOverlayCalendarEnabled, connectedCalendars, onToggleCalendar } = calendars;
const scrolledToTimeslotsOnce = useRef(false);
const embedUiConfig = useEmbedUiConfig();
@@ -276,7 +270,11 @@ const BookerComponent = ({
renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail();
const EventBooker = useMemo(() => {
return bookerState === "booking" ? (
if (bookerState !== "booking") {
return null;
}
return (
<BookEventForm
key={key}
timeslot={selectedTimeslot}
@@ -312,22 +310,18 @@ const BookerComponent = ({
backButton: customClassNames?.confirmStep?.backButton,
}}
isPlatform={isPlatform}>
<>
{!isPlatform && (
<RedirectToInstantMeetingModal
expiryTime={expiryTime}
bookingId={parseInt(getQueryParam("bookingId") || "0")}
instantVideoMeetingUrl={instantVideoMeetingUrl}
onGoBack={() => {
onGoBackInstantMeeting();
}}
orgName={event.data?.entity?.name}
/>
)}
</>
{!isPlatform && (
<RedirectToInstantMeetingModal
expiryTime={expiryTime}
bookingId={parseInt(getQueryParam("bookingId") || "0")}
instantVideoMeetingUrl={instantVideoMeetingUrl}
onGoBack={() => {
onGoBackInstantMeeting();
}}
orgName={event.data?.entity?.name}
/>
)}
</BookEventForm>
) : (
<></>
);
}, [
bookerFormErrorRef,
@@ -418,27 +412,23 @@ const BookerComponent = ({
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
isMobile={isMobile}
nextSlots={nextSlots}
renderOverlay={() =>
isEmbed ? (
<></>
) : (
<>
<OverlayCalendar
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
connectedCalendars={connectedCalendars}
loadingConnectedCalendar={loadingConnectedCalendar}
overlayBusyDates={overlayBusyDates}
onToggleCalendar={onToggleCalendar}
hasSession={hasSession}
handleClickContinue={onClickOverlayContinue}
handleSwitchStateChange={onOverlaySwitchStateChange}
handleClickNoCalendar={() => {
onOverlayClickNoCalendar();
}}
/>
</>
)
}
renderOverlay={() => {
if (isEmbed) return null;
return (
<OverlayCalendar
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
connectedCalendars={connectedCalendars}
overlayBusyDates={overlayBusyDates}
onToggleCalendar={onToggleCalendar}
hasSession={hasSession}
handleClickContinue={onClickOverlayContinue}
handleSwitchStateChange={onOverlaySwitchStateChange}
handleClickNoCalendar={() => {
onOverlayClickNoCalendar();
}}
/>
);
}}
/>
)}
</BookerSection>
@@ -1,18 +1,12 @@
"use client";
import { useSession } from "next-auth/react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useMemo, useCallback, useEffect } from "react";
import React from "react";
import { shallow } from "zustand/shallow";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks";
import type { BookerProps } from "@calcom/features/bookings/Booker";
import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
useInitializeBookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
@@ -22,27 +16,23 @@ import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useS
import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import {
useEvent,
useScheduleForEvent,
} from "@calcom/features/bookings/Booker/utils/event";
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import {
DEFAULT_LIGHT_BRAND_COLOR,
DEFAULT_DARK_BRAND_COLOR,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { localStorage } from "@calcom/lib/webstorage";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo } from "react";
import { shallow } from "zustand/shallow";
import { Booker as BookerComponent } from "./Booker";
export type BookerWebWrapperAtomProps = BookerProps & {
eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
};
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Element => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -64,17 +54,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const isRedirect = searchParams?.get("redirected") === "true" || false;
const fromUserNameRedirected = searchParams?.get("username") || "";
const rescheduleUid =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("rescheduleUid")
: null;
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const rescheduledBy =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("rescheduledBy")
: null;
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduledBy") : null;
const bookingUid =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("bookingUid")
: null;
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null;
const timezone = searchParams?.get("cal.tz") || null;
useEffect(() => {
@@ -102,10 +86,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
timezone,
});
const [dayCount] = useBookerStoreContext(
(state) => [state.dayCount, state.setDayCount],
shallow
);
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const { data: session } = useSession();
const routerQuery = useRouterQuery();
@@ -117,8 +98,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]:
searchParams?.get(key),
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
}),
{}
);
@@ -126,11 +106,8 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
return {
name:
searchParams?.get("name") ||
(firstNameQueryParam
? `${firstNameQueryParam} ${lastNameQueryParam}`
: null),
guests:
(searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
};
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
@@ -147,13 +124,10 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification:
event?.data?.requiresBookerEmailVerification,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
const slots = useSlots(
event?.data ? { id: event.data.id, length: event.data.length } : null
);
const slots = useSlots(event?.data ? { id: event.data.id, length: event.data.length } : null);
const isEmbed = useIsEmbed();
@@ -217,8 +191,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
);
useBrandColors({
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor:
event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
darkBrandColor: event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
theme: event.data?.profile.theme,
});
@@ -1,20 +1,14 @@
import { useEffect } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
import { useOverlayCalendar } from "@calcom/features/bookings/Booker/components/hooks/useOverlayCalendar";
import type { WrappedBookerPropsMain } from "@calcom/features/bookings/Booker/types";
import { useEffect } from "react";
import { OverlayCalendarContinueModal } from "./OverlayCalendarContinueModal";
import { OverlayCalendarSettingsModal } from "./OverlayCalendarSettingsModal";
import { OverlayCalendarSwitch } from "./OverlayCalendarSwitch";
type OverlayCalendarProps = Pick<
UseCalendarsReturnType,
| "connectedCalendars"
| "overlayBusyDates"
| "onToggleCalendar"
| "loadingConnectedCalendar"
| "isOverlayCalendarEnabled"
WrappedBookerPropsMain["calendars"],
"connectedCalendars" | "overlayBusyDates" | "onToggleCalendar" | "isOverlayCalendarEnabled"
> & {
handleClickNoCalendar: () => void;
hasSession: boolean;
@@ -27,12 +21,11 @@ export const OverlayCalendar = ({
overlayBusyDates,
onToggleCalendar,
isOverlayCalendarEnabled,
loadingConnectedCalendar,
handleClickNoCalendar,
handleSwitchStateChange,
handleClickContinue,
hasSession,
}: OverlayCalendarProps) => {
}: OverlayCalendarProps): JSX.Element | null => {
const isPlatform = useIsPlatform();
const {
handleCloseContinueModal,
@@ -70,10 +63,8 @@ export const OverlayCalendar = ({
/>
)}
<OverlayCalendarSettingsModal
connectedCalendars={connectedCalendars}
open={isOpenOverlaySettingsModal}
onClose={handleCloseSettingsModal}
isLoading={loadingConnectedCalendar}
onToggleConnectedCalendar={handleToggleConnectedCalendar}
onClickNoCalendar={() => {
handleClickNoCalendar();
@@ -1,34 +1,30 @@
import Link from "next/link";
import { Fragment } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { DialogContent, DialogClose } from "@calcom/ui/components/dialog";
import { DialogClose, DialogContent } from "@calcom/ui/components/dialog";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Switch } from "@calcom/ui/components/form";
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list";
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import Image from "next/image";
import Link from "next/link";
import { Fragment } from "react";
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
interface IOverlayCalendarSettingsModalProps {
type IOverlayCalendarSettingsModalProps = {
open?: boolean;
onClose?: (state: boolean) => void;
onClickNoCalendar?: () => void;
isLoading: boolean;
connectedCalendars: UseCalendarsReturnType["connectedCalendars"];
onToggleConnectedCalendar: (externalCalendarId: string, credentialId: number) => void;
checkIsCalendarToggled: (externalCalendarId: string, credentialId: number) => boolean;
}
};
const SkeletonLoader = () => {
const SkeletonLoader = (): JSX.Element => {
return (
<SkeletonContainer>
<div className="border-subtle mt-3 stack-y-4 rounded-xl border px-4 py-4 ">
<div className="stack-y-4 mt-3 rounded-xl border border-subtle px-4 py-4">
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
@@ -39,109 +35,97 @@ const SkeletonLoader = () => {
};
export function OverlayCalendarSettingsModal({
connectedCalendars,
isLoading,
open,
onClose,
onClickNoCalendar,
onToggleConnectedCalendar,
checkIsCalendarToggled,
}: IOverlayCalendarSettingsModalProps) {
}: IOverlayCalendarSettingsModalProps): JSX.Element {
const { t } = useLocale();
const isPlatform = useIsPlatform();
const { data, isPending: isLoading } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
enabled: !!open,
});
let content: React.ReactNode;
if (isLoading) {
content = <SkeletonLoader />;
} else if (data?.connectedCalendars.length === 0) {
content = (
<EmptyScreen
Icon="calendar"
headline={t("no_calendar_installed")}
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={onClickNoCalendar}
/>
);
} else {
content = data?.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && !item.calendars && <Alert severity="error" title={item.error.message} />}
{item?.error === undefined && item.calendars && (
<ListItem className="flex-col rounded-md">
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
{item.integration.logo && (
<Image
className={classNames(
"h-10 w-10",
item.integration.logo.includes("-dark") && "dark:invert"
)}
width={40}
height={40}
src={isPlatform ? `https://app.cal.com${item.integration.logo}` : item.integration.logo}
alt={`${item.integration.title} logo`}
/>
)}
<div className="grow truncate pl-2">
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
<Link href={`/apps/${item.integration.slug}`}>
{item.integration.name || item.integration.title}
</Link>
</ListItemTitle>
<ListItemText component="p">{item.primary?.email}</ListItemText>
</div>
</div>
<div className="border-subtle w-full border-t pt-4">
<ul className="stack-y-4">
{item.calendars.map((cal, index) => {
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
return (
<li className="flex gap-3" key={id}>
<Switch
id={id}
label={cal.name}
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
onCheckedChange={() => {
onToggleConnectedCalendar(cal.externalId, item.credentialId);
}}
/>
</li>
);
})}
</ul>
</div>
</ListItem>
)}
</Fragment>
));
}
return (
<>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
enableOverflow
type="creation"
title="Calendar Settings"
className="pb-4"
description={t("view_overlay_calendar_events")}>
<div className="no-scrollbar max-h-full overflow-y-scroll ">
{isLoading ? (
<SkeletonLoader />
) : (
<>
{connectedCalendars.length === 0 ? (
<EmptyScreen
Icon="calendar"
headline={t("no_calendar_installed")}
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={onClickNoCalendar}
/>
) : (
<>
{connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && !item.calendars && (
<Alert severity="error" title={item.error.message} />
)}
{item?.error === undefined && item.calendars && (
<ListItem className="flex-col rounded-md">
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
{
// eslint-disable-next-line @next/next/no-img-element
item.integration.logo && (
<img
className={classNames(
"h-10 w-10",
item.integration.logo.includes("-dark") && "dark:invert"
)}
src={
isPlatform
? `https://app.cal.com${item.integration.logo}`
: item.integration.logo
}
alt={`${item.integration.title} logo`}
/>
)
}
<div className="grow truncate pl-2">
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
<Link href={`/apps/${item.integration.slug}`}>
{item.integration.name || item.integration.title}
</Link>
</ListItemTitle>
<ListItemText component="p">{item.primary.email}</ListItemText>
</div>
</div>
<div className="border-subtle w-full border-t pt-4">
<ul className="stack-y-4">
{item.calendars.map((cal, index) => {
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
return (
<li className="flex gap-3" key={id}>
<Switch
id={id}
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
onCheckedChange={() => {
onToggleConnectedCalendar(cal.externalId, item.credentialId);
}}
/>
<label htmlFor={id}>{cal.name}</label>
</li>
);
})}
</ul>
</div>
</ListItem>
)}
</Fragment>
))}
</>
)}
</>
)}
</div>
<div className="mt-4 flex gap-2 self-end">
<DialogClose>{t("done")}</DialogClose>
</div>
</DialogContent>
</Dialog>
</>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
enableOverflow
type="creation"
title={t("calendar_settings")}
className="pb-4"
description={t("view_overlay_calendar_events")}>
<div className="no-scrollbar max-h-full overflow-y-scroll">{content}</div>
<div className="mt-4 flex gap-2 self-end">
<DialogClose>{t("done")}</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}
@@ -2934,6 +2934,7 @@
"add_new_user": "Add new user",
"this_app_is_not_setup_already": "This app has not been setup yet",
"as_csv": "as CSV",
"calendar_settings": "Calendar Settings",
"overlay_my_calendar": "Overlay my calendar",
"overlay_my_calendar_toc": "By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events": "View your calendar events to prevent clashed booking.",
@@ -1,18 +1,19 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../../../types/ical.d.ts"/>
import ICAL from "ical.js";
import process from "node:process";
import dayjs from "@calcom/dayjs";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import type {
Calendar,
IntegrationCalendar,
EventBusyDate,
CalendarEvent,
EventBusyDate,
GetAvailabilityParams,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import ICAL from "ical.js";
// for Apple's Travel Time feature only (for now)
const getTravelDurationInSeconds = (vevent: ICAL.Component) => {
@@ -301,7 +302,7 @@ class ICSFeedCalendarService implements Calendar {
name,
readOnly: true,
externalId: url,
integrationName: this.integrationName,
integration: this.integrationName,
};
});
}
@@ -1,6 +1,5 @@
import { create } from "zustand";
import type { EventBusyDate } from "@calcom/types/Calendar";
import { createWithEqualityFn } from "zustand/traditional";
interface IOverlayCalendarStore {
overlayBusyDates: EventBusyDate[] | undefined;
@@ -11,7 +10,7 @@ interface IOverlayCalendarStore {
setCalendarSettingsOverlayModal: (value: boolean) => void;
}
export const useOverlayCalendarStore = create<IOverlayCalendarStore>((set) => ({
export const useOverlayCalendarStore = createWithEqualityFn<IOverlayCalendarStore>((set) => ({
overlayBusyDates: undefined,
setOverlayBusyDates: (busyDates: EventBusyDate[]) => {
set({ overlayBusyDates: busyDates });
@@ -1,23 +1,16 @@
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { shallow } from "zustand/shallow";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { localStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { shallow } from "zustand/shallow";
import { useBookerStore } from "../../store";
import type { ToggledConnectedCalendars } from "../../types";
import { useOverlayCalendarStore } from "../OverlayCalendar/store";
import { useLocalSet } from "./useLocalSet";
export type UseCalendarsReturnType = ReturnType<typeof useCalendars>;
type UseCalendarsProps = {
hasSession: boolean;
};
export type ToggledConnectedCalendars = Set<{
credentialId: number;
externalId: string;
}>;
export const useCalendars = ({ hasSession }: UseCalendarsProps) => {
const searchParams = useSearchParams();
@@ -26,11 +19,11 @@ export const useCalendars = ({ hasSession }: UseCalendarsProps) => {
const switchEnabled =
searchParams?.get("overlayCalendar") === "true" ||
localStorage?.getItem("overlayCalendarSwitchDefault") === "true";
const { set, clearSet } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const utils = trpc.useUtils();
const [set, setSet] = useState<Set<ToggledConnectedCalendars>>(() => {
const storedValue = localStorage.getItem("toggledConnectedCalendars");
return storedValue ? new Set(JSON.parse(storedValue)) : new Set([]);
});
const [calendarSettingsOverlay] = useOverlayCalendarStore(
(state) => [state.calendarSettingsOverlayModal, state.setCalendarSettingsOverlayModal],
@@ -55,22 +48,25 @@ export const useCalendars = ({ hasSession }: UseCalendarsProps) => {
useEffect(
function refactorMeWithoutEffect() {
if (!isError) return;
clearSet();
setSet(new Set([]));
},
[isError]
);
const { data, isPending } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
enabled: !!calendarSettingsOverlay || Boolean(searchParams?.get("overlayCalendar")),
});
const { data, isPending } = trpc.viewer.calendars.connectedCalendars.useQuery(
{
skipSync: true,
},
{
enabled: !!calendarSettingsOverlay || Boolean(searchParams?.get("overlayCalendar")),
}
);
return {
overlayBusyDates,
isOverlayCalendarEnabled: switchEnabled,
connectedCalendars: data?.connectedCalendars || [],
loadingConnectedCalendar: isPending,
onToggleCalendar: (data: ToggledConnectedCalendars) => {
utils.viewer.availability.calendarOverlay.reset();
},
onToggleCalendar: setSet,
};
};
@@ -1,24 +1,30 @@
import { useEffect, useState } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { useEffect, useState } from "react";
import { shallow } from "zustand/shallow";
import type { ToggledConnectedCalendars, WrappedBookerPropsMain } from "../../types";
import { useOverlayCalendarStore } from "../OverlayCalendar/store";
import type { UseCalendarsReturnType } from "./useCalendars";
import { useLocalSet } from "./useLocalSet";
export type UseOverlayCalendarReturnType = ReturnType<typeof useOverlayCalendar>;
export const useOverlayCalendar = ({
connectedCalendars,
overlayBusyDates,
onToggleCalendar,
}: Pick<UseCalendarsReturnType, "connectedCalendars" | "overlayBusyDates" | "onToggleCalendar">) => {
const { set, toggleValue, hasItem } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
}: Pick<
WrappedBookerPropsMain["calendars"],
"overlayBusyDates" | "onToggleCalendar" | "connectedCalendars"
>): {
isOpenOverlayContinueModal: boolean;
isOpenOverlaySettingsModal: boolean;
handleCloseContinueModal: (val: boolean) => void;
handleCloseSettingsModal: (val: boolean) => void;
handleToggleConnectedCalendar: (externalCalendarId: string, credentialId: number) => void;
checkIsCalendarToggled: (externalId: string, credentialId: number) => boolean;
} => {
const { set, toggleValue, hasItem } = useLocalSet<ToggledConnectedCalendars>(
"toggledConnectedCalendars",
[]
);
const [initalised, setInitalised] = useState(false);
const [continueWithProvider, setContinueWithProvider] = useOverlayCalendarStore(
(state) => [state.continueWithProviderModal, state.setContinueWithProviderModal],
@@ -65,7 +71,7 @@ export const useOverlayCalendar = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasItem, set, initalised]);
const handleToggleConnectedCalendar = (externalCalendarId: string, credentialId: number) => {
const handleToggleConnectedCalendar = (externalCalendarId: string, credentialId: number): void => {
const calendarsToLoad = toggleValue({
credentialId: credentialId,
externalId: externalCalendarId,
+35 -2
View File
@@ -1,13 +1,13 @@
import type { UseBookerLayoutType } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import type { UseBookingsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
import type { UseSlotsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useSlots";
import type { UseVerifyCodeReturnType } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
import type { UseVerifyEmailReturnType } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
import type { useScheduleForEventReturnType } from "@calcom/features/bookings/Booker/utils/event";
import type { BookerEventQuery } from "@calcom/features/bookings/types";
import type { IntlSupportedTimeZones } from "@calcom/lib/timeZones";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type React from "react";
import type { GetBookingType } from "../lib/get-booking";
@@ -105,6 +105,11 @@ export interface BookerProps {
useApiV2?: boolean;
}
export type ToggledConnectedCalendars = {
credentialId: number;
externalId: string;
};
export type WrappedBookerPropsMain = {
sessionUsername?: string | null;
rescheduleUid: string | null;
@@ -121,7 +126,35 @@ export type WrappedBookerPropsMain = {
extraOptions: Record<string, string | string[]>;
bookings: UseBookingsReturnType;
slots: UseSlotsReturnType;
calendars: UseCalendarsReturnType;
calendars: {
overlayBusyDates?: EventBusyDate[];
isOverlayCalendarEnabled: boolean;
connectedCalendars: {
calendars?: {
name?: string;
integrationTitle?: string;
externalId: string;
primary: boolean | null;
}[];
credentialId: number;
delegationCredentialId?: string | null;
cacheUpdatedAt: null;
error?: {
message: string | null;
};
primary?: {
email?: string;
};
integration: {
slug: string;
name?: string | undefined;
title?: string;
logo: string;
};
}[];
loadingConnectedCalendar: boolean;
onToggleCalendar: (calendarsToLoad: Set<ToggledConnectedCalendars>) => void;
};
bookerForm: UseBookingFormReturnType;
event: BookerEventQuery;
schedule: useScheduleForEventReturnType;
@@ -1,14 +1,13 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import classNames from "classnames";
import { useEffect, useState } from "react";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
interface Props {
onChange: (value: { externalId: string; integration: string }) => void;
@@ -88,8 +87,7 @@ const DestinationCalendarSelector = ({
useEffect(() => {
const selected = connectedCalendarsList
?.map((connected) => connected.calendars ?? [])
.flat()
?.flatMap((connected) => connected.calendars ?? [])
.find((cal) => cal.externalId === value);
if (selected) {
@@ -140,7 +138,7 @@ const DestinationCalendarSelector = ({
`${t("create_events_on")}`
) : (
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
<Badge variant="blue">Default</Badge>{" "}
<Badge variant="blue">{t("default")}</Badge>{" "}
{destinationCalendar?.name &&
`${destinationCalendar.name} (${destinationCalendar?.integrationTitle} - ${destinationCalendar.primaryEmail})`}
</span>
@@ -1,5 +1,3 @@
import { sortBy } from "lodash";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
@@ -7,8 +5,7 @@ import dayjs from "@calcom/dayjs";
import getCalendarsEvents, {
getCalendarsEventsWithTimezones,
} from "@calcom/features/calendars/lib/getCalendarsEvents";
import { getUid } from "@calcom/lib/CalEventParser";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getRichDescription, getUid } from "@calcom/lib/CalEventParser";
import { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential";
@@ -27,9 +24,60 @@ import type {
} from "@calcom/types/Calendar";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
import { sortBy } from "lodash";
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
/**
* Process the calendar event by generating description and removing attendees if needed
*/
const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
if (calEvent.seatsPerTimeSlot) {
calEvent.responses = null;
calEvent.userFieldsResponses = null;
calEvent.additionalNotes = null;
calEvent.customInputs = null;
}
// Generate the calendar event description
const calendarEvent: CalendarServiceEvent = {
...calEvent,
calendarDescription: getRichDescription(calEvent),
};
const isMeetLocationType = calEvent.location === MeetLocationType;
// Determine if the calendar event should include attendees
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType) {
calendarEvent.attendees = [];
}
return calendarEvent;
};
type IntegrationWithCredentials = Awaited<
ReturnType<typeof getCalendarCredentials>
>[number]["integration"] & {
credentials?: CredentialPayload[];
credential?: CredentialPayload;
};
type CleanIntegration = Omit<IntegrationWithCredentials, "credentials" | "credential">;
/**
* Important function to prevent leaking credentials to the client
* @param appIntegration
* @returns App
*/
export const cleanIntegrationKeys = (appIntegration: IntegrationWithCredentials): CleanIntegration => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credentials, credential, ...rest } = appIntegration;
return rest;
};
export const getCalendarCredentials = (credentials: Array<CredentialForCalendarService>) => {
const calendarCredentials = getApps(credentials, true)
.filter((app) => app.type.endsWith("_calendar"))
@@ -49,11 +97,31 @@ export const getCalendarCredentialsWithoutDelegation = (credentials: CredentialP
return getCalendarCredentials(buildNonDelegationCredentials(credentials));
};
export type ConnectedCalendar = Omit<IntegrationCalendar, "primary"> & {
primary: boolean | null;
isSelected: boolean;
readOnly: boolean;
credentialId: number;
delegationCredentialId: string | null;
};
export const getConnectedCalendars = async (
calendarCredentials: ReturnType<typeof getCalendarCredentials>,
selectedCalendars: { externalId: string }[],
destinationCalendarExternalId?: string
) => {
): Promise<{
connectedCalendars: {
integration: CleanIntegration;
calendars?: ConnectedCalendar[];
credentialId: number;
delegationCredentialId?: string | null;
error?: {
message: string;
};
primary?: ConnectedCalendar;
}[];
destinationCalendar: IntegrationCalendar | undefined;
}> => {
const connectedCalendars = await Promise.all(
calendarCredentials.map(async (item) => {
try {
@@ -82,7 +150,7 @@ export const getConnectedCalendars = async (
};
}
const cals = await calendarInstance.listCalendars();
const calendars = sortBy(
const calendars: ConnectedCalendar[] = sortBy(
cals.map((cal: IntegrationCalendar) => {
return {
...cal,
@@ -165,22 +233,6 @@ export const getConnectedCalendars = async (
return { connectedCalendars, destinationCalendar };
};
/**
* Important function to prevent leaking credentials to the client
* @param appIntegration
* @returns App
*/
const cleanIntegrationKeys = (
appIntegration: Awaited<ReturnType<typeof getCalendarCredentials>>[number]["integration"] & {
credentials?: Array<CredentialPayload>;
credential: CredentialPayload;
}
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credentials, credential, ...rest } = appIntegration;
return rest;
};
/**
* This function deduplicates credentials based on selected calendars
* It removes regular credentials for which corresponding delegation credentials exist which can be identified only associating them with selected calendars
@@ -311,7 +363,7 @@ export const createEvent = async (
const uid: string = getUid(formattedEvent.uid);
const calendar = await getCalendar(credential, "booking");
let success = true;
let calError: string | undefined = undefined;
let calError: string | undefined;
log.debug(
"Creating calendar event",
@@ -410,7 +462,7 @@ export const updateEvent = async (
const uid = getUid(calEvent.uid);
const calendar = await getCalendar(credential, "booking");
let success = false;
let calError: string | undefined = undefined;
let calError: string | undefined;
let calWarnings: string[] | undefined = [];
log.debug(
"Updating calendar event",
@@ -513,33 +565,3 @@ export const deleteEvent = async ({
return Promise.resolve({});
};
/**
* Process the calendar event by generating description and removing attendees if needed
*/
const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
if (calEvent.seatsPerTimeSlot) {
calEvent.responses = null;
calEvent.userFieldsResponses = null;
calEvent.additionalNotes = null;
calEvent.customInputs = null;
}
// Generate the calendar event description
const calendarEvent: CalendarServiceEvent = {
...calEvent,
calendarDescription: getRichDescription(calEvent),
};
const isMeetLocationType = calEvent.location === MeetLocationType;
// Determine if the calendar event should include attendees
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType) {
calendarEvent.attendees = [];
}
return calendarEvent;
};
@@ -1,5 +1,7 @@
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import {
cleanIntegrationKeys,
getCalendarCredentials,
getConnectedCalendars,
} from "@calcom/features/calendars/lib/CalendarManager";
@@ -12,28 +14,13 @@ import prisma from "@calcom/prisma";
import type { DestinationCalendar, SelectedCalendar, User } from "@calcom/prisma/client";
import { AppCategories } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { IntegrationCalendar } from "@calcom/types/Calendar";
const log = logger.getSubLogger({ prefix: ["getConnectedDestinationCalendarsAndEnsureDefaultsInDb"] });
type ReturnTypeGetConnectedCalendars = Awaited<ReturnType<typeof getConnectedCalendars>>;
type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"];
export type UserWithCalendars = Pick<User, "id" | "email"> & {
allSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
>[];
userLevelSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
>[];
destinationCalendar: DestinationCalendar | null;
};
export type ConnectedDestinationCalendars = Awaited<
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
>;
/**
* Ensures that when DelegationCredential is enabled and there is already a calendar connected for the corresponding domain, we only allow the DelegationCredential calendar to be returned
* This is to ensure that duplicate calendar connections aren't shown in UI(apps/installed/calendars). We choose DelegationCredential connection to be shown because we don't want users to be able to work with individual calendars
@@ -51,7 +38,7 @@ const _ensureNoConflictingNonDelegatedConnectedCalendar = <
connectedCalendars: T[];
loggedInUser: { email: string };
}) => {
return connectedCalendars.filter((connectedCalendar, index, array) => {
return connectedCalendars.filter((connectedCalendar, _index, array) => {
const allCalendarsWithSameAppSlug = array.filter(
(cal) => cal.integration.slug === connectedCalendar.integration.slug
);
@@ -213,12 +200,13 @@ function findMatchingCalendar({
}: {
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
calendar: DestinationCalendar;
}) {
}): Required<ConnectedCalendarsFromGetConnectedCalendars[number]>["calendars"][number] | undefined {
// Check if destinationCalendar exists in connectedCalendars
const allCals = connectedCalendars.flatMap((cal) => cal.calendars ?? []);
const matchingCalendar = allCals.find(
(cal) => cal.externalId === calendar.externalId && cal.integration === calendar.integration
);
if (!matchingCalendar) return;
return matchingCalendar;
}
@@ -254,13 +242,32 @@ function getSelectedCalendars({
}: {
user: UserWithCalendars;
eventTypeId: number | null;
}): Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId">[] {
}): Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId" | "id"
>[] {
if (eventTypeId) {
return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId);
}
return user.userLevelSelectedCalendars;
}
export type UserWithCalendars = Pick<User, "id" | "email"> & {
allSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId" | "id"
>[];
userLevelSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId" | "id"
>[];
destinationCalendar: DestinationCalendar | null;
};
export type ConnectedDestinationCalendars = Awaited<
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
>;
/**
* Fetches the calendars for the authenticated user or the event-type if provided
* It also takes care of updating the destination calendar in some edge cases
@@ -270,12 +277,17 @@ export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
onboarding,
eventTypeId,
prisma,
skipSync,
}: {
user: UserWithCalendars;
onboarding: boolean;
eventTypeId?: number | null;
prisma: PrismaClient;
}) {
skipSync?: boolean;
}): Promise<{
destinationCalendar: DestinationCalendar & Omit<IntegrationCalendar, "id" | "userId">;
connectedCalendars: Awaited<ReturnType<typeof getConnectedCalendars>>["connectedCalendars"];
}> {
const userCredentials = await prisma.credential.findMany({
where: {
userId: user.id,
@@ -284,84 +296,124 @@ export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
enabled: true,
},
},
select: credentialForCalendarServiceSelect,
select: {
selectedCalendars: {
select: {
id: true,
},
},
...credentialForCalendarServiceSelect,
},
});
const selectedCalendars = getSelectedCalendars({ user, eventTypeId: eventTypeId ?? null });
let connectedCalendars: Awaited<ReturnType<typeof getConnectedCalendars>>["connectedCalendars"] = [];
let destinationCalendar: IntegrationCalendar | undefined;
const { credentials: allCredentials } = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: { id: user.id, email: user.email, credentials: userCredentials },
});
const selectedCalendars = getSelectedCalendars({ user, eventTypeId: eventTypeId ?? null });
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(allCredentials);
// get all the connected integrations' calendars (from third party)
const getConnectedCalendarsResult = await getConnectedCalendars(
calendarCredentials,
selectedCalendars,
user.destinationCalendar?.externalId
);
let connectedCalendars = getConnectedCalendarsResult.connectedCalendars;
const destinationCalendar = getConnectedCalendarsResult.destinationCalendar;
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
if (connectedCalendars.length === 0) {
user = await handleNoConnectedCalendars(user);
} else if (!user.destinationCalendar) {
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleNoDestinationCalendar({
user,
connectedCalendars,
onboarding,
}));
} else {
/* There are connected calendars and a destination calendar */
log.debug(
`There are connected calendars and a destination calendar, so check if destinationCalendar exists in connectedCalendars for user ${user.id}`
if (!skipSync) {
// get all the connected integrations' calendars (from third party)
const getConnectedCalendarsResult = await getConnectedCalendars(
calendarCredentials,
selectedCalendars,
user.destinationCalendar?.externalId
);
const destinationCal = findMatchingCalendar({ connectedCalendars, calendar: user.destinationCalendar });
if (!destinationCal) {
connectedCalendars = getConnectedCalendarsResult.connectedCalendars;
destinationCalendar = getConnectedCalendarsResult.destinationCalendar;
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
if (connectedCalendars.length === 0) {
user = await handleNoConnectedCalendars(user);
} else if (!user.destinationCalendar) {
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleDestinationCalendarNotInConnectedCalendars({
await handleNoDestinationCalendar({
user,
connectedCalendars,
onboarding,
}));
} else if (onboarding && !destinationCal.isSelected) {
} else {
/* There are connected calendars and a destination calendar */
log.debug(
`Onboarding:Destination calendar is not selected, but in connectedCalendars, so mark it as selected in the calendar list for user ${user.id}`
`There are connected calendars and a destination calendar, so check if destinationCalendar exists in connectedCalendars for user ${user.id}`
);
// Mark the destination calendar as selected in the calendar list
// We use every so that we can exit early once we find the matching calendar
connectedCalendars.every((cal) => {
const index = (cal.calendars || []).findIndex(
(calendar) =>
calendar.externalId === destinationCal.externalId &&
calendar.integration === destinationCal.integration
);
if (index >= 0 && cal.calendars) {
cal.calendars[index].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId: destinationCal.externalId,
integration: destinationCal.integration || "",
};
return false;
}
return true;
const destinationCal = findMatchingCalendar({ connectedCalendars, calendar: user.destinationCalendar });
if (!destinationCal) {
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleDestinationCalendarNotInConnectedCalendars({
user,
connectedCalendars,
onboarding,
}));
} else if (onboarding && !destinationCal.isSelected) {
log.debug(
`Onboarding:Destination calendar is not selected, but in connectedCalendars, so mark it as selected in the calendar list for user ${user.id}`
);
// Mark the destination calendar as selected in the calendar list
// We use every so that we can exit early once we find the matching calendar
connectedCalendars.every((cal) => {
const index = (cal.calendars || []).findIndex(
(calendar) =>
calendar.externalId === destinationCal.externalId &&
calendar.integration === destinationCal.integration
);
if (index >= 0 && cal.calendars) {
cal.calendars[index].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId: destinationCal.externalId,
integration: destinationCal.integration || "",
};
return false;
}
return true;
});
}
}
// Insert the newly toggled record to the DB
if (calendarToEnsureIsEnabledForConflictCheck) {
await ensureSelectedCalendarIsInDb({
user,
selectedCalendar: calendarToEnsureIsEnabledForConflictCheck,
eventTypeId: eventTypeId ?? null,
});
}
}
// very explicit about skipping sync.
if (skipSync) {
// TODO: Make calendar types more flexible so this isn't needed
calendarCredentials.map(async (item) => {
const { integration } = item;
// TODO: Make calendar types more flexible somehow so this isn't needed
const credential: typeof item.credential & { selectedCalendars: { id: string }[] } =
item.credential as CredentialDataWithTeamName & { selectedCalendars: { id: string }[] };
// Insert the newly toggled record to the DB
if (calendarToEnsureIsEnabledForConflictCheck) {
await ensureSelectedCalendarIsInDb({
user,
selectedCalendar: calendarToEnsureIsEnabledForConflictCheck,
eventTypeId: eventTypeId ?? null,
const safeToSendIntegration = cleanIntegrationKeys(integration);
connectedCalendars.push({
integration: safeToSendIntegration,
credentialId: credential.id,
delegationCredentialId: credential.delegationCredentialId,
calendars: selectedCalendars
.filter((cal) =>
credential.selectedCalendars.some((appSelectedCal) => appSelectedCal.id === cal.id)
)
.map((cal) => ({
...cal,
isSelected: true,
readOnly: false,
primary: null,
credentialId: credential.id,
delegationCredentialId: credential.delegationCredentialId,
})),
});
});
}
@@ -369,11 +421,18 @@ export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
connectedCalendars,
loggedInUser: { email: user.email },
});
let destinationCalendarWithoutIdAndUserId: Omit<IntegrationCalendar, "id" | "userId"> | null = null;
if (destinationCalendar) {
// ID and userID will be provided by user.destinationCalendar
const { id: _id, userId: _userId, ...partialDestCal } = destinationCalendar;
destinationCalendarWithoutIdAndUserId = partialDestCal;
}
return {
connectedCalendars: noConflictingNonDelegatedConnectedCalendars,
destinationCalendar: {
...(user.destinationCalendar as DestinationCalendar),
...destinationCalendar,
// biome-ignore lint/style/noNonNullAssertion: destinationCalendar is guaranteed to be non null here
...user.destinationCalendar!,
...destinationCalendarWithoutIdAndUserId,
},
};
}
@@ -1110,6 +1110,7 @@ export class UserRepository {
movedToProfileId: true,
selectedCalendars: {
select: {
id: true,
eventTypeId: true,
externalId: true,
integration: true,
@@ -1,15 +1,15 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { SingleValueComponent } from "@calcom/features/calendars/DestinationCalendarSelector";
import { OptionComponent } from "@calcom/features/calendars/DestinationCalendarSelector";
import {
OptionComponent,
SingleValueComponent,
} from "@calcom/features/calendars/DestinationCalendarSelector";
import type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Select } from "@calcom/ui/components/form";
import { useEffect, useMemo, useState } from "react";
import { getPlaceholderContent } from "../lib/getPlaceholderContent";
export type DestinationCalendarProps = {
@@ -43,8 +43,7 @@ export const DestinationCalendarSelector = ({
useEffect(() => {
const selected = connectedCalendars
.map((connected) => connected.calendars ?? [])
.flat()
.flatMap((connected) => connected.calendars ?? [])
.find((cal) => cal.externalId === value);
if (selected) {
@@ -52,15 +51,16 @@ export const DestinationCalendarSelector = ({
integration.calendars?.some((calendar) => calendar.externalId === selected.externalId)
);
const label = selected.name ? `${selected.name} ` : "";
setSelectedOption({
value: `${selected.integration}:${selected.externalId}`,
label: selected.name ? `${selected.name} ` : "",
label,
subtitle: `(${selectedIntegration?.integration.title?.replace(/calendar/i, "")} - ${
selectedIntegration?.primary?.name
})`,
});
}
}, [connectedCalendars]);
}, [connectedCalendars, value]);
const options = useMemo(() => {
return (
@@ -95,8 +95,8 @@ export const DestinationCalendarSelector = ({
!hidePlaceholder ? (
`${t("create_events_on")}`
) : (
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
<Badge variant="blue">Default</Badge>{" "}
<span className="min-w-0 overflow-hidden truncate whitespace-nowrap text-default">
<Badge variant="blue">{t("default")}</Badge>{" "}
{destinationCalendar?.primaryEmail &&
`${destinationCalendar.name} (${destinationCalendar?.integrationTitle} - ${destinationCalendar.primaryEmail})`}
</span>
@@ -119,7 +119,7 @@ export const DestinationCalendarSelector = ({
}}
isSearchable={false}
className={classNames(
"border-default my-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm text-sm"
"my-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-default text-sm"
)}
onChange={(newValue) => {
setSelectedOption(newValue);
+2
View File
@@ -51,7 +51,9 @@ export type {
} from "@calcom/features/bookings/types";
export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
export type { ConnectedCalendar } from "@calcom/features/calendars/lib/CalendarManager";
export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
export type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler";
export { cityTimezonesHandler } from "@calcom/features/cityTimezones/cityTimezonesHandler";
@@ -1,12 +1,12 @@
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { PrismaClient } from "@prisma/client";
import type { TConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
type ConnectedCalendarsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TConnectedCalendarsInputSchema;
};
@@ -23,10 +23,9 @@ type ConnectedCalendarsHandlerResult = {
};
export const connectedCalendarsHandler = async ({
ctx,
ctx: { user, prisma },
input,
}: ConnectedCalendarsOptions): Promise<ConnectedCalendarsHandlerResult> => {
const { user } = ctx;
const onboarding = input?.onboarding || false;
const { connectedCalendars, destinationCalendar } =
@@ -34,6 +33,7 @@ export const connectedCalendarsHandler = async ({
user,
onboarding,
eventTypeId: input?.eventTypeId ?? null,
skipSync: input?.skipSync ?? false,
prisma,
});
@@ -6,6 +6,7 @@ export type TConnectedCalendarsInputSchema =
// Fetches the calendars for this event-type only if present
// Otherwise, fetches the calendars for the authenticated user
eventTypeId?: number | null;
skipSync?: boolean;
}
| undefined;
@@ -15,5 +16,6 @@ export const ZConnectedCalendarsInputSchema: z.ZodType<TConnectedCalendarsInputS
// Fetches the calendars for this event-type only if present
// Otherwise, fetches the calendars for the authenticated user
eventTypeId: z.number().nullish(),
skipSync: z.boolean().optional(),
})
.optional();
+4 -2
View File
@@ -249,15 +249,17 @@ export interface AdditionalInformation {
hangoutLink?: string;
}
export interface IntegrationCalendar extends Ensure<Partial<_SelectedCalendar>, "externalId"> {
export interface IntegrationCalendar extends Ensure<Partial<_SelectedCalendar>, "externalId" | "integration"> {
primary?: boolean;
name?: string;
readOnly?: boolean;
// For displaying the connected email address
email?: string;
primaryEmail?: string;
primaryEmail?: string | null;
credentialId?: number | null;
integrationTitle?: string;
integration: string;
customCalendarReminder?: DestinationCalendar["customCalendarReminder"];
}
/**
@@ -1,11 +1,9 @@
import cx from "@calcom/ui/classNames";
import { useId } from "@radix-ui/react-id";
import * as Label from "@radix-ui/react-label";
import * as PrimitiveSwitch from "@radix-ui/react-switch";
import type React from "react";
import type { ReactNode } from "react";
import React from "react";
import cx from "@calcom/ui/classNames";
import { Tooltip } from "../../tooltip";
const Wrapper = ({ children, tooltip }: { tooltip?: string; children: React.ReactNode }) => {
@@ -76,7 +74,7 @@ export const Switch = (
htmlFor={id}
className={cx(
"text-emphasis font-medium",
size === "sm" ? "m-1 text-xs" : labelOnLeading ? "mr-2 mt-1 text-sm" : "ml-2 mt-1 text-sm",
size === "sm" ? "m-1 text-xs" : labelOnLeading ? "mr-2 text-sm" : "ml-2 text-sm",
primitiveProps.disabled ? "cursor-not-allowed opacity-25" : "cursor-pointer",
labelOnLeading && "flex-1"
)}>