From b1ebefb85427379af0d0198ca1892a79adc7ac6a Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 13 Mar 2025 23:47:24 +0530 Subject: [PATCH] feat: don't update query params for booker atom (#20000) * feat: Add `omitUpdatingParams` prop to Booker store for query param control * remove console * Update booking.tsx * fix: type check failing * undo changes * feat: Expose `onBookerStateChange` callback to track `booker-store` updates * add `onBookerStateChange` docs * Update store.ts * added `allowUpdatingUrlParams` for backwards compatibility --- docs/platform/atoms/booker.mdx | 2 + packages/features/bookings/Booker/store.ts | 62 +++++++++++++++---- .../atoms/booker/BookerPlatformWrapper.tsx | 59 +++++++++++++++++- packages/platform/atoms/booker/types.ts | 27 ++++++++ .../examples/base/src/pages/booking.tsx | 6 ++ 5 files changed, 141 insertions(+), 15 deletions(-) diff --git a/docs/platform/atoms/booker.mdx b/docs/platform/atoms/booker.mdx index c62c43a57f..efc95db129 100644 --- a/docs/platform/atoms/booker.mdx +++ b/docs/platform/atoms/booker.mdx @@ -120,6 +120,8 @@ Below is a list of props that can be passed to the booker atom. | view | No | Specifies the layout of the booker atom into column, week, or month view | | metadata | No | Used to pass custom metadata values into the booker. Metadata should be an object eg: `{ bookingSource: "website", userRole: "admin" }` | | bannerUrl | No | Adds custom banner to the booker atom | +| onBookerStateChange | No | Callback function that is triggered when the state of the booker atom changes. | +| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. | Along with the props, booker atom accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.

diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 591544cf79..584a9f9640 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -36,6 +36,8 @@ type StoreInitializeType = { teamMemberEmail?: string | null; crmOwnerRecordType?: string | null; crmAppSlug?: string | null; + isPlatform?: boolean; + allowUpdatingUrlParams?: boolean; }; type SeatedEventData = { @@ -161,6 +163,8 @@ export type BookerStore = { teamMemberEmail?: string | null; crmOwnerRecordType?: string | null; crmAppSlug?: string | null; + isPlatform?: boolean; + allowUpdatingUrlParams?: boolean; }; /** @@ -180,7 +184,9 @@ export const useBookerStore = create((set, get) => ({ if (["week_view", "column_view"].includes(layout) && !get().selectedDate) { set({ selectedDate: dayjs().format("YYYY-MM-DD") }); } - updateQueryParam("layout", layout); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("layout", layout); + } return set({ layout }); }, selectedDate: getQueryParam("date") || null, @@ -194,14 +200,14 @@ export const useBookerStore = create((set, get) => ({ const currentSelection = dayjs(get().selectedDate); const newSelection = dayjs(selectedDate); set({ selectedDate }); - if (!omitUpdatingParams) { + if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) { updateQueryParam("date", selectedDate ?? ""); } // Setting month make sure small calendar in fullscreen layouts also updates. if (newSelection.month() !== currentSelection.month()) { set({ month: newSelection.format("YYYY-MM") }); - if (!omitUpdatingParams) { + if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) { updateQueryParam("month", newSelection.format("YYYY-MM")); } } @@ -223,11 +229,15 @@ export const useBookerStore = create((set, get) => ({ if (newSelection.month() !== currentSelection.month()) { set({ month: newSelection.format("YYYY-MM") }); - updateQueryParam("month", newSelection.format("YYYY-MM")); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("month", newSelection.format("YYYY-MM")); + } } set({ selectedDate: newSelectionFormatted }); - updateQueryParam("date", newSelectionFormatted); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("date", newSelectionFormatted); + } }, username: null, eventSlug: null, @@ -249,7 +259,9 @@ export const useBookerStore = create((set, get) => ({ return; } set({ month, selectedTimeslot: null }); - updateQueryParam("month", month ?? ""); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("month", month ?? ""); + } get().setSelectedDate(null); }, dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null, @@ -265,10 +277,13 @@ export const useBookerStore = create((set, get) => ({ }, setSeatedEventData: (seatedEventData: SeatedEventData) => { set({ seatedEventData }); - updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null"); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null"); + } }, // This is different from timeZone in timePreferencesStore, because timeZone in timePreferencesStore is the preferred timezone of the booker, // it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link. + // it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link. timezone: getQueryParam("cal.tz") ?? null, setTimezone: (timezone: string | null) => { set({ timezone }); @@ -291,6 +306,8 @@ export const useBookerStore = create((set, get) => ({ teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isPlatform = false, + allowUpdatingUrlParams = true, }: StoreInitializeType) => { const selectedDateInStore = get().selectedDate; @@ -330,6 +347,8 @@ export const useBookerStore = create((set, get) => ({ teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isPlatform, + allowUpdatingUrlParams, }); if (durationConfig?.includes(Number(getQueryParam("duration")))) { @@ -353,7 +372,9 @@ export const useBookerStore = create((set, get) => ({ "minutes" ); set({ selectedDuration: originalBookingLength }); - updateQueryParam("duration", originalBookingLength ?? ""); + if (!isPlatform || allowUpdatingUrlParams) { + updateQueryParam("duration", originalBookingLength ?? ""); + } } if (month) set({ month }); @@ -367,9 +388,12 @@ export const useBookerStore = create((set, get) => ({ selectedTimeslot, isInstantMeeting, }); - updateQueryParam("month", month); - updateQueryParam("date", selectedDate ?? ""); - updateQueryParam("slot", selectedTimeslot ?? "", false); + + if (!isPlatform || allowUpdatingUrlParams) { + updateQueryParam("month", month); + updateQueryParam("date", selectedDate ?? ""); + updateQueryParam("slot", selectedTimeslot ?? "", false); + } } //removeQueryParam("layout"); }, @@ -377,7 +401,9 @@ export const useBookerStore = create((set, get) => ({ selectedDuration: null, setSelectedDuration: (selectedDuration: number | null) => { set({ selectedDuration }); - updateQueryParam("duration", selectedDuration ?? ""); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("duration", selectedDuration ?? ""); + } }, setBookingData: (bookingData: GetBookingType | null | undefined) => { set({ bookingData: bookingData ?? null }); @@ -396,7 +422,9 @@ export const useBookerStore = create((set, get) => ({ }, setSelectedTimeslot: (selectedTimeslot: string | null) => { set({ selectedTimeslot }); - updateQueryParam("slot", selectedTimeslot ?? "", false); + if (!get().isPlatform || get().allowUpdatingUrlParams) { + updateQueryParam("slot", selectedTimeslot ?? "", false); + } }, formValues: {}, setFormValues: (formValues: Record) => { @@ -406,6 +434,8 @@ export const useBookerStore = create((set, get) => ({ setOrg: (org: string | null | undefined) => { set({ org }); }, + isPlatform: false, + allowUpdatingUrlParams: true, })); export const useInitializeBookerStore = ({ @@ -426,6 +456,8 @@ export const useInitializeBookerStore = ({ teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isPlatform = false, + allowUpdatingUrlParams = true, }: StoreInitializeType) => { const initializeStore = useBookerStore((state) => state.initialize); useEffect(() => { @@ -447,6 +479,8 @@ export const useInitializeBookerStore = ({ teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isPlatform, + allowUpdatingUrlParams, }); }, [ initializeStore, @@ -467,5 +501,7 @@ export const useInitializeBookerStore = ({ teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isPlatform, + allowUpdatingUrlParams, ]); }; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index ea67c57a27..c928667f3a 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -1,5 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useMemo, useEffect, useCallback, useState } from "react"; +// eslint-disable-next-line no-restricted-imports +import debounce from "lodash/debounce"; +import { useMemo, useEffect, useCallback, useState, useRef } from "react"; import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; @@ -40,6 +42,7 @@ import { AtomsWrapper } from "../src/components/atoms-wrapper"; import type { BookerPlatformWrapperAtomPropsForIndividual, BookerPlatformWrapperAtomPropsForTeam, + BookerStoreValues, } from "./types"; export const BookerPlatformWrapper = ( @@ -53,6 +56,8 @@ export const BookerPlatformWrapper = ( crmAppSlug, crmOwnerRecordType, preventEventTypeRedirect, + onBookerStateChange, + allowUpdatingUrlParams = false, } = props; const layout = BookerLayouts[view]; @@ -70,6 +75,48 @@ export const BookerPlatformWrapper = ( const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState( Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault")) ); + const prevStateRef = useRef(null); + const getStateValues = useCallback( + (state: ReturnType): BookerStoreValues => { + return Object.fromEntries( + Object.entries(state).filter(([_, value]) => typeof value !== "function") + ) as BookerStoreValues; + }, + [] + ); + const debouncedStateChange = useMemo(() => { + return debounce( + (currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => { + const prevState = prevStateRef.current; + const stateChanged = !prevState || JSON.stringify(prevState) !== JSON.stringify(currentStateValues); + + if (stateChanged) { + callback(currentStateValues); + prevStateRef.current = currentStateValues; + } + }, + 50 + ); + }, []); + + useEffect(() => { + if (!onBookerStateChange) return; + + const unsubscribe = useBookerStore.subscribe((state) => { + const currentStateValues = getStateValues(state); + debouncedStateChange(currentStateValues, onBookerStateChange); + }); + + // Initial call with current state + const initialState = getStateValues(useBookerStore.getState()); + onBookerStateChange(initialState); + prevStateRef.current = initialState; + + return () => { + unsubscribe(); + debouncedStateChange.cancel(); + }; + }, [onBookerStateChange, getStateValues, debouncedStateChange]); useGetBookingForReschedule({ uid: props.rescheduleUid ?? props.bookingUid ?? "", @@ -166,6 +213,8 @@ export const BookerPlatformWrapper = ( org: props.entity?.orgSlug, username, bookingData, + isPlatform: true, + allowUpdatingUrlParams, }); const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); const selectedDate = useBookerStore((state) => state.selectedDate); @@ -397,6 +446,13 @@ export const BookerPlatformWrapper = ( }, [setIsOverlayCalendarEnabled] ); + const selectedDateProp = useMemo( + () => dayjs(props.selectedDate).format("YYYY-MM-DD"), + [props.selectedDate] + ); + useEffect(() => { + setSelectedDate(selectedDateProp, true); + }, [selectedDateProp]); useEffect(() => { // reset booker whenever it's unmounted @@ -408,7 +464,6 @@ export const BookerPlatformWrapper = ( setSelectedDuration(null); setOrg(null); setSelectedMonth(null); - setSelectedDuration(null); if (props.rescheduleUid) { // clean booking data from cache queryClient.removeQueries({ diff --git a/packages/platform/atoms/booker/types.ts b/packages/platform/atoms/booker/types.ts index a7daa643ca..16a2e0df88 100644 --- a/packages/platform/atoms/booker/types.ts +++ b/packages/platform/atoms/booker/types.ts @@ -1,4 +1,5 @@ import type { BookerProps } from "@calcom/features/bookings/Booker"; +import type { BookerStore } from "@calcom/features/bookings/Booker/store"; import type { BookingResponse } from "@calcom/platform-libraries"; import type { ApiSuccessResponse, @@ -10,6 +11,30 @@ import type { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { UseCreateBookingInput } from "../hooks/bookings/useCreateBooking"; +// Type that includes only the data values from BookerStore (excluding functions) +export type BookerStoreValues = Omit< + BookerStore, + | "setState" + | "setLayout" + | "setSelectedDate" + | "setSelectedDatesAndTimes" + | "addToSelectedDate" + | "setVerifiedEmail" + | "setMonth" + | "setDayCount" + | "setSeatedEventData" + | "setTimezone" + | "initialize" + | "setSelectedDuration" + | "setBookingData" + | "setRecurringEventCount" + | "setOccurenceCount" + | "setTentativeSelectedTimeslots" + | "setSelectedTimeslot" + | "setFormValues" + | "setOrg" +>; + export type BookerPlatformWrapperAtomProps = Omit< BookerProps, "username" | "entity" | "isTeamEvent" | "teamId" @@ -39,6 +64,7 @@ export type BookerPlatformWrapperAtomProps = Omit< onReserveSlotError?: (data: ApiErrorResponse) => void; onDeleteSlotSuccess?: (data: ApiSuccessResponseWithoutData) => void; onDeleteSlotError?: (data: ApiErrorResponse) => void; + onBookerStateChange?: (state: BookerStoreValues) => void; locationUrl?: string; view?: VIEW_TYPE; metadata?: Record; @@ -46,6 +72,7 @@ export type BookerPlatformWrapperAtomProps = Omit< onDryRunSuccess?: () => void; hostsLimit?: number; preventEventTypeRedirect?: boolean; + allowUpdatingUrlParams?: boolean; }; type VIEW_TYPE = keyof typeof BookerLayouts; diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index 462957d94e..8d63c82849 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -101,6 +101,10 @@ export default function Bookings(props: { calUsername: string; calEmail: string router.push(`/${data.data.uid}`); } }} + onBookerStateChange={(bookerState) => { + console.log("Booker state updated:", bookerState); + // You can perform any actions based on the updated state here + }} metadata={{ CustomKey: "CustomValue" }} duration={eventTypeDuration} customClassNames={{ @@ -120,6 +124,8 @@ export default function Bookings(props: { calUsername: string; calEmail: string ? { isTeamEvent: true, teamId: teams?.[0]?.id || 0 } : { username: props.calUsername })} hostsLimit={3} + selectedDate={new Date("2025-03-25")} + allowUpdatingUrlParams={true} /> )}