diff --git a/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx b/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx index 4df5172488..7146960d24 100644 --- a/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx +++ b/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx @@ -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} diff --git a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx index 01b6628c04..c442c701ca 100644 --- a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx +++ b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx @@ -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) => {
{name}
-

+

{name} {/* Temporarily removed till we use it on another place */} {/* @@ -36,7 +36,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {

+ className="mt-1 max-w-44 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm text-subtle"> {externalId}{" "}
@@ -49,7 +49,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => { {t("edit")} */}
-
+
    {calendars?.map((calendar, i) => ( diff --git a/apps/web/modules/bookings/components/Booker.tsx b/apps/web/modules/bookings/components/Booker.tsx index c82fa8cc92..f9afe96e62 100644 --- a/apps/web/modules/bookings/components/Booker.tsx +++ b/apps/web/modules/bookings/components/Booker.tsx @@ -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 ( - <> - {!isPlatform && ( - { - onGoBackInstantMeeting(); - }} - orgName={event.data?.entity?.name} - /> - )} - + {!isPlatform && ( + { + onGoBackInstantMeeting(); + }} + orgName={event.data?.entity?.name} + /> + )} - ) : ( - <> ); }, [ bookerFormErrorRef, @@ -418,27 +412,23 @@ const BookerComponent = ({ extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays} isMobile={isMobile} nextSlots={nextSlots} - renderOverlay={() => - isEmbed ? ( - <> - ) : ( - <> - { - onOverlayClickNoCalendar(); - }} - /> - - ) - } + renderOverlay={() => { + if (isEmbed) return null; + return ( + { + onOverlayClickNoCalendar(); + }} + /> + ); + }} /> )} diff --git a/apps/web/modules/bookings/components/BookerWebWrapper.tsx b/apps/web/modules/bookings/components/BookerWebWrapper.tsx index 38da38b114..4562ec11f2 100644 --- a/apps/web/modules/bookings/components/BookerWebWrapper.tsx +++ b/apps/web/modules/bookings/components/BookerWebWrapper.tsx @@ -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>>; }; -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, }); diff --git a/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendar.tsx b/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendar.tsx index cf5aae3bd3..7fd9ed27dc 100644 --- a/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendar.tsx +++ b/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendar.tsx @@ -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 = ({ /> )} { handleClickNoCalendar(); diff --git a/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx b/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx index 2bdb6d9134..e22ebe7b96 100644 --- a/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx +++ b/apps/web/modules/bookings/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx @@ -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 ( -
    +
    @@ -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 = ; + } else if (data?.connectedCalendars.length === 0) { + content = ( + + ); + } else { + content = data?.connectedCalendars.map((item) => ( + + {item.error && !item.calendars && } + {item?.error === undefined && item.calendars && ( + +
    + {item.integration.logo && ( + {`${item.integration.title} + )} +
    + + + {item.integration.name || item.integration.title} + + + {item.primary?.email} +
    +
    +
    +
      + {item.calendars.map((cal, index) => { + const id = cal.integrationTitle ?? `calendar-switch-${index}`; + return ( +
    • + { + onToggleConnectedCalendar(cal.externalId, item.credentialId); + }} + /> +
    • + ); + })} +
    +
    +
    + )} +
    + )); + } return ( - <> - - -
    - {isLoading ? ( - - ) : ( - <> - {connectedCalendars.length === 0 ? ( - - ) : ( - <> - {connectedCalendars.map((item) => ( - - {item.error && !item.calendars && ( - - )} - {item?.error === undefined && item.calendars && ( - -
    - { - // eslint-disable-next-line @next/next/no-img-element - item.integration.logo && ( - {`${item.integration.title} - ) - } -
    - - - {item.integration.name || item.integration.title} - - - {item.primary.email} -
    -
    -
    -
      - {item.calendars.map((cal, index) => { - const id = cal.integrationTitle ?? `calendar-switch-${index}`; - return ( -
    • - { - onToggleConnectedCalendar(cal.externalId, item.credentialId); - }} - /> - -
    • - ); - })} -
    -
    -
    - )} -
    - ))} - - )} - - )} -
    - -
    - {t("done")} -
    -
    -
    - + + +
    {content}
    +
    + {t("done")} +
    +
    +
    ); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 23ec816bb1..59a8a17d91 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/packages/app-store/ics-feedcalendar/lib/CalendarService.ts b/packages/app-store/ics-feedcalendar/lib/CalendarService.ts index 016f7a54e1..a0c039b45d 100644 --- a/packages/app-store/ics-feedcalendar/lib/CalendarService.ts +++ b/packages/app-store/ics-feedcalendar/lib/CalendarService.ts @@ -1,18 +1,19 @@ /* eslint-disable @typescript-eslint/triple-slash-reference */ /// -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, }; }); } diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/store.ts b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts index 9187e90595..51d3c902f5 100644 --- a/packages/features/bookings/Booker/components/OverlayCalendar/store.ts +++ b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts @@ -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((set) => ({ +export const useOverlayCalendarStore = createWithEqualityFn((set) => ({ overlayBusyDates: undefined, setOverlayBusyDates: (busyDates: EventBusyDate[]) => { set({ overlayBusyDates: busyDates }); diff --git a/packages/features/bookings/Booker/components/hooks/useCalendars.ts b/packages/features/bookings/Booker/components/hooks/useCalendars.ts index d17a79c81e..7f1e26119a 100644 --- a/packages/features/bookings/Booker/components/hooks/useCalendars.ts +++ b/packages/features/bookings/Booker/components/hooks/useCalendars.ts @@ -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; 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>(() => { + 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, }; }; diff --git a/packages/features/bookings/Booker/components/hooks/useOverlayCalendar.ts b/packages/features/bookings/Booker/components/hooks/useOverlayCalendar.ts index 92e482fe9d..364f76eab1 100644 --- a/packages/features/bookings/Booker/components/hooks/useOverlayCalendar.ts +++ b/packages/features/bookings/Booker/components/hooks/useOverlayCalendar.ts @@ -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; - export const useOverlayCalendar = ({ connectedCalendars, overlayBusyDates, onToggleCalendar, -}: Pick) => { - 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", + [] + ); 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, diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index fe35a33f66..23016cec89 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -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; 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) => void; + }; bookerForm: UseBookingFormReturnType; event: BookerEventQuery; schedule: useScheduleForEventReturnType; diff --git a/packages/features/calendars/DestinationCalendarSelector.tsx b/packages/features/calendars/DestinationCalendarSelector.tsx index 7c17ea438a..c093f81669 100644 --- a/packages/features/calendars/DestinationCalendarSelector.tsx +++ b/packages/features/calendars/DestinationCalendarSelector.tsx @@ -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")}` ) : ( - Default{" "} + {t("default")}{" "} {destinationCalendar?.name && `${destinationCalendar.name} (${destinationCalendar?.integrationTitle} - ${destinationCalendar.primaryEmail})`} diff --git a/packages/features/calendars/lib/CalendarManager.ts b/packages/features/calendars/lib/CalendarManager.ts index e3d63da85e..64d6141167 100644 --- a/packages/features/calendars/lib/CalendarManager.ts +++ b/packages/features/calendars/lib/CalendarManager.ts @@ -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 +>[number]["integration"] & { + credentials?: CredentialPayload[]; + credential?: CredentialPayload; +}; + +type CleanIntegration = Omit; + +/** + * 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) => { 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 & { + primary: boolean | null; + isSelected: boolean; + readOnly: boolean; + credentialId: number; + delegationCredentialId: string | null; +}; + export const getConnectedCalendars = async ( calendarCredentials: ReturnType, 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>[number]["integration"] & { - credentials?: Array; - 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; -}; diff --git a/packages/features/calendars/lib/getConnectedDestinationCalendars.ts b/packages/features/calendars/lib/getConnectedDestinationCalendars.ts index 5d26fffb93..39d6fd7231 100644 --- a/packages/features/calendars/lib/getConnectedDestinationCalendars.ts +++ b/packages/features/calendars/lib/getConnectedDestinationCalendars.ts @@ -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>; type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"]; -export type UserWithCalendars = Pick & { - allSelectedCalendars: Pick< - SelectedCalendar, - "externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId" - >[]; - userLevelSelectedCalendars: Pick< - SelectedCalendar, - "externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId" - >[]; - destinationCalendar: DestinationCalendar | null; -}; - -export type ConnectedDestinationCalendars = Awaited< - ReturnType ->; - /** * 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["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[] { +}): 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 & { + 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 +>; + /** * 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; + connectedCalendars: Awaited>["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>["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 | 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, }, }; } diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 47476a2ab6..9c1a01e94e 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1110,6 +1110,7 @@ export class UserRepository { movedToProfileId: true, selectedCalendars: { select: { + id: true, eventTypeId: true, externalId: true, integration: true, diff --git a/packages/platform/atoms/destination-calendar/DestinationCalendarSelector.tsx b/packages/platform/atoms/destination-calendar/DestinationCalendarSelector.tsx index 0f49a39c60..9f7a17c35d 100644 --- a/packages/platform/atoms/destination-calendar/DestinationCalendarSelector.tsx +++ b/packages/platform/atoms/destination-calendar/DestinationCalendarSelector.tsx @@ -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")}` ) : ( - - Default{" "} + + {t("default")}{" "} {destinationCalendar?.primaryEmail && `${destinationCalendar.name} (${destinationCalendar?.integrationTitle} - ${destinationCalendar.primaryEmail})`} @@ -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); diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index f967479efb..58578f5eda 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -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"; diff --git a/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts b/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts index 791b278db8..a02b048078 100644 --- a/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts +++ b/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts @@ -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; + prisma: PrismaClient; }; input: TConnectedCalendarsInputSchema; }; @@ -23,10 +23,9 @@ type ConnectedCalendarsHandlerResult = { }; export const connectedCalendarsHandler = async ({ - ctx, + ctx: { user, prisma }, input, }: ConnectedCalendarsOptions): Promise => { - 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, }); diff --git a/packages/trpc/server/routers/viewer/calendars/connectedCalendars.schema.ts b/packages/trpc/server/routers/viewer/calendars/connectedCalendars.schema.ts index a8caf4d88c..60821783f7 100644 --- a/packages/trpc/server/routers/viewer/calendars/connectedCalendars.schema.ts +++ b/packages/trpc/server/routers/viewer/calendars/connectedCalendars.schema.ts @@ -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, "externalId"> { +export interface IntegrationCalendar extends Ensure, "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"]; } /** diff --git a/packages/ui/components/form/switch/Switch.tsx b/packages/ui/components/form/switch/Switch.tsx index b4f4862082..b3374385a4 100644 --- a/packages/ui/components/form/switch/Switch.tsx +++ b/packages/ui/components/form/switch/Switch.tsx @@ -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" )}>