diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index e36281c2c8..f54395e553 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -410,7 +410,12 @@ const BookerComponent = ({ {layout !== BookerLayouts.MONTH_VIEW && !(layout === "mobile" && bookerState === "booking") && (
- +
)} @@ -442,7 +447,8 @@ const BookerComponent = ({ datePickerToggle: customClassNames?.datePickerCustomClassNames?.datePickerToggle, }} event={event} - schedule={schedule} + slots={schedule?.data?.slots} + isLoading={schedule.isPending} scrollToTimeSlots={scrollToTimeSlots} /> diff --git a/packages/features/bookings/Booker/components/DatePicker.test.tsx b/packages/features/bookings/Booker/components/DatePicker.test.tsx new file mode 100644 index 0000000000..26c645dee3 --- /dev/null +++ b/packages/features/bookings/Booker/components/DatePicker.test.tsx @@ -0,0 +1,108 @@ +import { render } from "@testing-library/react"; +import { vi, afterEach } from "vitest"; + +import dayjs from "@calcom/dayjs"; +import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker"; + +import { DatePicker } from "./DatePicker"; + +vi.mock("@calcom/features/calendars/DatePicker", () => { + return { + DatePicker: vi.fn(() =>
), + }; +}); + +const noop = () => { + /* noop */ +}; + +describe("Tests for DatePicker Component", () => { + afterEach(() => { + DatePickerComponent.mockClear(); + }); + + test("It passes the loading prop to the DatePicker component", async () => { + render(); + + expect(DatePickerComponent).toHaveBeenCalledWith( + expect.objectContaining({ + isLoading: true, + }), + expect.anything() + ); + }); + + test("happy path, slots in current month", async () => { + // today current time. + const testDate = dayjs(); + render( + + ); + // slot date is next month, so it'll have skipped the current month + // by setting the browsingDate to the next month. + expect(DatePickerComponent).toHaveBeenCalledWith( + expect.objectContaining({ + browsingDate: dayjs().startOf("month"), + }), + expect.anything() + ); + }); + + test("when there are only slots in the next month, skip the current month", async () => { + // there'll be one slot open on this day, next month. + const slotDate = dayjs().add("1", "month"); + render( + + ); + // slot date is next month, so it'll have skipped the current month + // by setting the browsingDate to the next month. + expect(DatePickerComponent).toHaveBeenCalledWith( + expect.objectContaining({ + browsingDate: slotDate.startOf("month"), + }), + expect.anything() + ); + }); + + test("when there are no slots, check for infinite loop (skip only 1 month)", async () => { + render( + + ); + // slot date is next month, so it'll have skipped the current month + // by setting the browsingDate to the next month. + expect(DatePickerComponent).toHaveBeenCalledWith( + expect.objectContaining({ + browsingDate: dayjs().add("1", "month").startOf("month"), + }), + expect.anything() + ); + }); +}); diff --git a/packages/features/bookings/Booker/components/DatePicker.tsx b/packages/features/bookings/Booker/components/DatePicker.tsx index 4eaeac0c2d..3317f1c75d 100644 --- a/packages/features/bookings/Booker/components/DatePicker.tsx +++ b/packages/features/bookings/Booker/components/DatePicker.tsx @@ -2,25 +2,65 @@ import { shallow } from "zustand/shallow"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; -import { default as DatePickerComponent } from "@calcom/features/calendars/DatePicker"; +import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import { weekdayToWeekIndex } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { User } from "@calcom/prisma/client"; +import type { Slots } from "../../types"; import { useBookerStore } from "../store"; -import type { useScheduleForEventReturnType } from "../utils/event"; + +const useMoveToNextMonthOnNoAvailability = ({ + browsingDate, + nonEmptyScheduleDays, + onMonthChange, + isLoading, +}: { + browsingDate: Dayjs; + nonEmptyScheduleDays: string[]; + isLoading: boolean; + onMonthChange: (date: Dayjs) => void; +}) => { + if (isLoading) { + return { + moveToNextMonthOnNoAvailability: () => { + /* return noop until ready */ + }, + }; + } + + const nonEmptyScheduleDaysInBrowsingMonth = nonEmptyScheduleDays.filter((date) => + dayjs(date).isSame(browsingDate, "month") + ); + + const moveToNextMonthOnNoAvailability = () => { + const currentMonth = dayjs().startOf("month").format("YYYY-MM"); + const browsingMonth = browsingDate.format("YYYY-MM"); + // Not meeting the criteria to move to next month + // Has to be currentMonth and it must have all days unbookable + if (currentMonth != browsingMonth || nonEmptyScheduleDaysInBrowsingMonth.length) { + return; + } + onMonthChange(browsingDate.add(1, "month")); + }; + return { + moveToNextMonthOnNoAvailability, + }; +}; export const DatePicker = ({ event, - schedule, + slots = {}, + isLoading, classNames, scrollToTimeSlots, }: { event: { data?: { subsetOfUsers: Pick[] } | null; }; - schedule: useScheduleForEventReturnType; + slots?: Slots; + isLoading?: boolean; classNames?: { datePickerContainer?: string; datePickerTitle?: string; @@ -33,12 +73,11 @@ export const DatePicker = ({ }) => { const { i18n } = useLocale(); const [month, selectedDate] = useBookerStore((state) => [state.month, state.selectedDate], shallow); + const [setSelectedDate, setMonth, setDayCount] = useBookerStore( (state) => [state.setSelectedDate, state.setMonth, state.setDayCount], shallow ); - const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots); - const browsingDate = month ? dayjs(month) : dayjs().startOf("month"); const onMonthChange = (date: Dayjs) => { setMonth(date.format("YYYY-MM")); @@ -46,26 +85,16 @@ export const DatePicker = ({ setDayCount(null); // Whenever the month is changed, we nullify getting X days }; - const moveToNextMonthOnNoAvailability = () => { - const currentMonth = dayjs().startOf("month").format("YYYY-MM"); - const browsingMonth = browsingDate.format("YYYY-MM"); - - // Insufficient data case - if (!schedule?.data?.slots) { - return; - } - - // Not meeting the criteria to move to next month - // Has to be currentMonth and it must have all days unbookable - if (currentMonth != browsingMonth || nonEmptyScheduleDays.length) { - return; - } - - onMonthChange(browsingDate.add(1, "month")); - }; + const nonEmptyScheduleDays = useNonEmptyScheduleDays(slots); + const browsingDate = month ? dayjs(month) : dayjs().startOf("month"); + const { moveToNextMonthOnNoAvailability } = useMoveToNextMonthOnNoAvailability({ + browsingDate, + nonEmptyScheduleDays, + onMonthChange, + isLoading: isLoading ?? true, + }); moveToNextMonthOnNoAvailability(); - return ( { setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"), omitUpdatingParams); }} @@ -86,7 +115,7 @@ export const DatePicker = ({ browsingDate={month ? dayjs(month) : undefined} selected={dayjs(selectedDate)} weekStart={weekdayToWeekIndex(event?.data?.subsetOfUsers?.[0]?.weekStart)} - slots={schedule?.data?.slots} + slots={slots} scrollToTimeSlots={scrollToTimeSlots} /> ); diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 584a9f9640..9bc1603ce2 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect } from "react"; -import { create } from "zustand"; +import { createWithEqualityFn } from "zustand/traditional"; import dayjs from "@calcom/dayjs"; import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants"; @@ -174,7 +174,7 @@ export type BookerStore = { * * See comments in interface above for more information on it's specific values. */ -export const useBookerStore = create((set, get) => ({ +export const useBookerStore = createWithEqualityFn((set, get) => ({ state: "loading", setState: (state: BookerState) => set({ state }), layout: BookerLayouts.MONTH_VIEW, diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index df800b5753..0eee6a9e86 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -13,16 +13,16 @@ import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import type { IOutOfOfficeData } from "@calcom/lib/getUserAvailability"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; -import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util"; +import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; import { Icon } from "@calcom/ui/components/icon"; import { SkeletonText } from "@calcom/ui/components/skeleton"; -import { Button } from "@calcom/ui/components/button"; -import classNames from "@calcom/ui/classNames"; import { useBookerTime } from "../Booker/components/hooks/useBookerTime"; import { useBookerStore } from "../Booker/store"; import { getQueryParam } from "../Booker/utils/query-param"; import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay"; +import type { Slots } from "../types"; import { SeatsAvailabilityText } from "./SeatsAvailabilityText"; type TOnTimeSelect = ( @@ -45,7 +45,7 @@ type TOnTentativeTimeSelect = ({ }) => void; export type AvailableTimesProps = { - slots: IGetAvailableSlots["slots"][string]; + slots: Slots[string]; showTimeFormatToggle?: boolean; className?: string; // It is called when a slot is selected, but it is not a confirmation and a confirm button will be shown besides it. diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 783f01810c..c6bbde38de 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -5,6 +5,8 @@ import type { BookingCreateBody } from "@calcom/prisma/zod/custom/booking"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { AppsStatus } from "@calcom/types/Calendar"; +import type { DatePickerProps } from "../calendars/DatePicker"; + export type PublicEvent = NonNullable; export type BookerEventQuery = { @@ -26,6 +28,9 @@ type BookerEventUser = Pick< type BookerEventProfile = Pick; +// marked as required to keep responsibility on consumers to handle the case where slots is undefined +export type Slots = Required>; + export type BookerEvent = Pick< PublicEvent, | "id" diff --git a/packages/features/calendars/DatePicker.test.tsx b/packages/features/calendars/DatePicker.test.tsx new file mode 100644 index 0000000000..07a1cffdfc --- /dev/null +++ b/packages/features/calendars/DatePicker.test.tsx @@ -0,0 +1,41 @@ +import { render } from "@testing-library/react"; + +import dayjs from "@calcom/dayjs"; + +import { DatePicker } from "./DatePicker"; + +const noop = () => { + /* noop */ +}; + +describe("Tests for DatePicker Component", () => { + test("Should render correctly with default date", async () => { + const testDate = dayjs("2024-02-20"); + const { getByTestId } = render(); + + const selectedMonthLabel = getByTestId("selected-month-label"); + await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM")); + }); + + test("Should render with the minimum date if browsingDate < minDate", async () => { + const testDate = dayjs("2024-02-20"); + const minDate = dayjs("2025-02-10"); + const { getByTestId } = render( + + ); + + const selectedMonthLabel = getByTestId("selected-month-label"); + await expect(selectedMonthLabel).toHaveAttribute("dateTime", minDate.format("YYYY-MM")); + }); + + test("Should render with the browsingDate date if browsingDate >= minDate", async () => { + const testDate = dayjs("2025-03-20"); + const minDate = dayjs("2025-02-10"); + const { getByTestId } = render( + + ); + + const selectedMonthLabel = getByTestId("selected-month-label"); + await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM")); + }); +}); diff --git a/packages/features/calendars/DatePicker.tsx b/packages/features/calendars/DatePicker.tsx index 3128016841..7173b0a369 100644 --- a/packages/features/calendars/DatePicker.tsx +++ b/packages/features/calendars/DatePicker.tsx @@ -10,9 +10,9 @@ import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns"; import type { IFromUser, IToUser } from "@calcom/lib/getUserAvailability"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { weekdayNames } from "@calcom/lib/weekday"; -import { SkeletonText } from "@calcom/ui/components/skeleton"; -import { Button } from "@calcom/ui/components/button"; import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; export type DatePickerProps = { /** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */ @@ -36,7 +36,7 @@ export type DatePickerProps = { /** allows adding classes to the container */ className?: string; /** Shows a small loading spinner next to the month name */ - isPending?: boolean; + isLoading?: boolean; /** used to query the multiple selected dates */ eventSlug?: string; /** To identify days that are not available and should display OOO and redirect if toUser exists */ @@ -54,7 +54,7 @@ export type DatePickerProps = { >; }; -export const Day = ({ +const Day = ({ date, active, disabled, @@ -256,7 +256,7 @@ const Days = ({
{day === null ? (
- ) : props.isPending ? ( + ) : props.isLoading ? (