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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,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"));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 [];
|
||||
|
||||
Reference in New Issue
Block a user