fix: skip when browsing month has no available slots (#19626)

* fix: skip when browsing month has no available slots

* Fix zustand error + add wip tests

* Fix issue with using brows
ingDate too early

* Use slots instead of schedule return types to remove hard coupling to tRPC

* Some await fixes + use type definition from extended core feature

* Move useNextMonth to custom hook

* Add unit tests to cover the next month scenario

* Use changed interface, schedule -> slots,isLoading

* Return no-op until ready
This commit is contained in:
Alex van Andel
2025-03-25 17:28:50 +00:00
committed by GitHub
parent d131d58b19
commit 0772e1d168
12 changed files with 240 additions and 46 deletions
+8 -2
View File
@@ -410,7 +410,12 @@ const BookerComponent = ({
{layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3">
<DatePicker event={event} schedule={schedule} scrollToTimeSlots={scrollToTimeSlots} />
<DatePicker
event={event}
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
/>
</div>
)}
</BookerSection>
@@ -442,7 +447,8 @@ const BookerComponent = ({
datePickerToggle: customClassNames?.datePickerCustomClassNames?.datePickerToggle,
}}
event={event}
schedule={schedule}
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
/>
</BookerSection>
@@ -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(() => <div data-testid="mock-date-picker" />),
};
});
const noop = () => {
/* noop */
};
describe("Tests for DatePicker Component", () => {
afterEach(() => {
DatePickerComponent.mockClear();
});
test("It passes the loading prop to the DatePicker component", async () => {
render(<DatePicker event={{}} isLoading={true} />);
expect(DatePickerComponent).toHaveBeenCalledWith(
expect.objectContaining({
isLoading: true,
}),
expect.anything()
);
});
test("happy path, slots in current month", async () => {
// today current time.
const testDate = dayjs();
render(
<DatePicker
event={{}}
slots={{
[`${testDate.format("YYYY-MM-DD")}`]: [
{
time: testDate.format("YYYY-MM-DDTHH:mm:ss"),
},
],
}}
isLoading={false}
/>
);
// 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(
<DatePicker
event={{}}
slots={{
[`${slotDate.format("YYYY-MM-DD")}`]: [
{
time: slotDate.format("YYYY-MM-DDTHH:mm:ss"),
},
],
}}
isLoading={false}
/>
);
// 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(
<DatePicker
event={{}}
slots={
{
/* no slots given at all, but not loading */
}
}
isLoading={false}
/>
);
// 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()
);
});
});
@@ -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<User, "weekStart">[] } | 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 (
<DatePickerComponent
customClassNames={{
@@ -76,7 +105,7 @@ export const DatePicker = ({
datePickerToggle: classNames?.datePickerToggle,
}}
className={classNames?.datePickerContainer}
isPending={schedule.isPending}
isLoading={isLoading}
onChange={(date: Dayjs | null, omitUpdatingParams?: boolean) => {
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}
/>
);
+2 -2
View File
@@ -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<BookerStore>((set, get) => ({
export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
state: "loading",
setState: (state: BookerState) => set({ state }),
layout: BookerLayouts.MONTH_VIEW,
@@ -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.
+5
View File
@@ -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<RouterOutputs["viewer"]["public"]["event"]>;
export type BookerEventQuery = {
@@ -26,6 +28,9 @@ type BookerEventUser = Pick<
type BookerEventProfile = Pick<PublicEvent["profile"], "name" | "image" | "bookerLayouts">;
// marked as required to keep responsibility on consumers to handle the case where slots is undefined
export type Slots = Required<NonNullable<DatePickerProps["slots"]>>;
export type BookerEvent = Pick<
PublicEvent,
| "id"
@@ -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(<DatePicker onChange={noop} browsingDate={testDate} locale="en" />);
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(
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" />
);
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(
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate} locale="en" />
);
const selectedMonthLabel = getByTestId("selected-month-label");
await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM"));
});
});
+14 -9
View File
@@ -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 = ({
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
{day === null ? (
<div key={`e-${idx}`} />
) : props.isPending ? (
) : props.isLoading ? (
<button
className="bg-muted text-muted absolute bottom-0 left-0 right-0 top-0 mx-auto flex w-full items-center justify-center rounded-sm border-transparent text-center font-medium opacity-90 transition"
key={`e-${idx}`}
@@ -283,7 +283,7 @@ const Days = ({
</div>
))}
{!props.isPending && !isBookingInPast && includedDates && includedDates?.length === 0 && (
{!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && (
<NoAvailabilityOverlay month={month} nextMonthButton={nextMonthButton} />
)}
</>
@@ -311,7 +311,11 @@ const DatePicker = ({
};
scrollToTimeSlots?: () => void;
}) => {
const browsingDate = passThroughProps.browsingDate || dayjs().startOf("month");
const minDate = passThroughProps.minDate;
const rawBrowsingDate = passThroughProps.browsingDate || dayjs().startOf("month");
const browsingDate =
minDate && rawBrowsingDate.valueOf() < minDate.valueOf() ? dayjs(minDate) : rawBrowsingDate;
const { i18n, t } = useLocale();
const bookingData = useBookerStore((state) => state.bookingData);
const isBookingInPast = bookingData ? new Date(bookingData.endTime) < new Date() : false;
@@ -332,7 +336,7 @@ const DatePicker = ({
<div className="mb-1 flex items-center justify-between text-xl">
<span className="text-default w-1/2 text-base">
{browsingDate ? (
<>
<time dateTime={browsingDate.format("YYYY-MM")} data-testid="selected-month-label">
<strong
className={classNames(`text-emphasis font-semibold`, customClassNames?.datePickerTitle)}>
{month}
@@ -340,7 +344,7 @@ const DatePicker = ({
<span className={classNames(`text-subtle font-medium`, customClassNames?.datePickerTitle)}>
{browsingDate.format("YYYY")}
</span>
</>
</time>
) : (
<SkeletonText className="h-8 w-24" />
)}
@@ -410,4 +414,5 @@ const DatePicker = ({
);
};
export { DatePicker, Day };
export default DatePicker;
+1 -1
View File
@@ -354,7 +354,7 @@ const EmailEmbed = ({
<CollapsibleContent>
<div className="text-default text-sm">{t("select_date")}</div>
<DatePicker
isPending={schedule.isPending}
isLoading={schedule.isPending}
onChange={(date: Dayjs | null) => {
setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"));
}}
+1 -1
View File
@@ -1,3 +1,3 @@
export * from "./components";
export type { Slots, Slot } from "./lib/use-schedule";
export type { Slots, Slot } from "./lib/use-schedule/types";
export { useSchedule, useSlotsForDate, useNonEmptyScheduleDays } from "./lib/use-schedule";
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import type { Slots } from "../use-schedule";
import type { Slots } from "../use-schedule/types";
export const getNonEmptyScheduleDays = (slots?: Slots) => {
if (typeof slots === "undefined") return [];