fix: Improves dialog window that appears when there is no availability in a month. Adding a description to clarify limits when rolling or range period types are in use. (#20451)

* init

* updated typing, english localization text, used existing calculatePeriodLimits and isTimeViolatingFutureLimit to determine if go to next month button should be disabled

* improving copy

* semantic dialog heading tag, proper EN localization,

* reverting

* force local to origin

* Translations by LLM

* intit tests

* moving NoAvailability to its own file.

* refactor overlay component to encpsulate logic in hooks

* improved tests

* remove unintended change

* fix: period data type import. Providing default periodData values

* fix: type errors on event object

* fix: updates case in type import

* removing translations

* remove unnecessary import

* remove unnecessary import

* improving mocks for booker and logger

* moving tests to __tests__ folder

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
This commit is contained in:
Shawn CazaandTushar Bhatt authored and GitHub committed 2025-04-15 23:46:01 +05:30
1 parent 1584dd3179
commit 417acdaacb
11 files changed
+334 -28

No files matched your search

@@ -1869,6 +1869,9 @@
"wrong_code": "Wrong verification code",
"not_verified": "Not yet verified",
"no_availability_in_month": "No availability in {{month}}",
"no_availability_rolling": "Scheduling is only available up to {{days}} in advance. Please check again soon.",
"no_availability_range":
"Scheduling ended on {{date}}. Please check again soon.",
"view_previous_month": "View previous month",
"view_next_month": "View next month",
"send_code": "Send code",
@@ -63,6 +63,12 @@ const mockEvent = {
isPending: false,
};
vi.mock("../../../calendars/NoAvailabilityDialog", () => ({
default: () => {
return null;
},
}));
const mockSchedule = {
data: {
slots: {
@@ -7,6 +7,7 @@ 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 { PeriodData } from "@calcom/types/Event";
import type { Slots } from "../../types";
import { useBookerStore } from "../store";
@@ -57,7 +58,14 @@ export const DatePicker = ({
scrollToTimeSlots,
}: {
event: {
data?: { subsetOfUsers: Pick<User, "weekStart">[] } | null;
data?: {
subsetOfUsers: Pick<User, "weekStart">[];
periodType?: PeriodData["periodType"];
periodStartDate?: PeriodData["periodStartDate"];
periodEndDate?: PeriodData["periodEndDate"];
periodDays?: PeriodData["periodDays"];
periodCountCalendarDays?: PeriodData["periodCountCalendarDays"];
} | null;
};
slots?: Slots;
isLoading?: boolean;
@@ -95,6 +103,23 @@ export const DatePicker = ({
isLoading: isLoading ?? true,
});
moveToNextMonthOnNoAvailability();
const periodData: PeriodData = {
...{
periodType: "UNLIMITED",
periodStartDate: null,
periodEndDate: null,
periodDays: null,
periodCountCalendarDays: false,
},
...(event?.data && {
periodType: event.data.periodType,
periodStartDate: event.data.periodStartDate,
periodEndDate: event.data.periodEndDate,
periodDays: event.data.periodDays,
periodCountCalendarDays: event.data.periodCountCalendarDays,
}),
};
return (
<DatePickerComponent
customClassNames={{
@@ -117,6 +142,7 @@ export const DatePicker = ({
weekStart={weekdayToWeekIndex(event?.data?.subsetOfUsers?.[0]?.weekStart)}
slots={slots}
scrollToTimeSlots={scrollToTimeSlots}
periodData={periodData}
/>
);
};
+20 -22
View File
@@ -10,10 +10,13 @@ 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 type { PeriodData } from "@calcom/types/Event";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import NoAvailabilityDialog from "./NoAvailabilityDialog";
export type DatePickerProps = {
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
@@ -52,6 +55,7 @@ export type DatePickerProps = {
emoji?: string;
}[]
>;
periodData?: PeriodData;
};
const Day = ({
@@ -111,25 +115,6 @@ const Day = ({
);
};
const NoAvailabilityOverlay = ({
month,
nextMonthButton,
}: {
month: string | null;
nextMonthButton: () => void;
}) => {
const { t } = useLocale();
return (
<div className="bg-muted border-subtle absolute left-1/2 top-40 -mt-10 w-max -translate-x-1/2 -translate-y-1/2 transform rounded-md border p-8 shadow-sm">
<h4 className="text-emphasis mb-4 font-medium">{t("no_availability_in_month", { month: month })}</h4>
<Button onClick={nextMonthButton} color="primary" EndIcon="arrow-right" data-testid="view_next_month">
{t("view_next_month")}
</Button>
</div>
);
};
const Days = ({
minDate,
excludedDates = [],
@@ -143,6 +128,7 @@ const Days = ({
slots,
customClassName,
isBookingInPast,
periodData,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
@@ -156,6 +142,7 @@ const Days = ({
};
scrollToTimeSlots?: () => void;
isBookingInPast: boolean;
periodData: PeriodData;
}) => {
// Create placeholder elements for empty days in first week
const weekdayOfFirst = browsingDate.date(1).day();
@@ -282,9 +269,13 @@ const Days = ({
)}
</div>
))}
{!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && (
<NoAvailabilityOverlay month={month} nextMonthButton={nextMonthButton} />
<NoAvailabilityDialog
month={month}
nextMonthButton={nextMonthButton}
browsingDate={browsingDate}
periodData={periodData}
/>
)}
</>
);
@@ -299,6 +290,13 @@ const DatePicker = ({
slots,
customClassNames,
includedDates,
periodData = {
periodStartDate: null,
periodEndDate: null,
periodCountCalendarDays: null,
periodDays: null,
periodType: "UNLIMITED",
},
...passThroughProps
}: DatePickerProps &
Partial<React.ComponentProps<typeof Days>> & {
@@ -319,7 +317,6 @@ const DatePicker = ({
const { i18n, t } = useLocale();
const bookingData = useBookerStore((state) => state.bookingData);
const isBookingInPast = bookingData ? new Date(bookingData.endTime) < new Date() : false;
const changeMonth = (newMonth: number) => {
if (onMonthChange) {
onMonthChange(browsingDate.add(newMonth, "month"));
@@ -408,6 +405,7 @@ const DatePicker = ({
slots={slots}
includedDates={includedDates}
isBookingInPast={isBookingInPast}
periodData={periodData}
/>
</div>
</div>
@@ -0,0 +1,104 @@
import { useState } from "react";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { calculatePeriodLimits, isTimeViolatingFutureLimit } from "@calcom/lib/isOutOfBounds";
import type { PeriodData } from "@calcom/types/Event";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
// Determines if the next month will pass the booking limits for 'ROLLING' and 'RANGE' period types
const useNoFutureAvailability = (browsingDate: Dayjs, periodData: PeriodData) => {
const { periodDays, periodType, periodCountCalendarDays, periodEndDate, periodStartDate } = periodData;
const periodLimits = calculatePeriodLimits({
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
allDatesWithBookabilityStatusInBookerTz: null, // Temporary workaround
_skipRollingWindowCheck: true,
eventUtcOffset: 0,
bookerUtcOffset: 0,
});
// Check if limit is before start of next month
const isOutOfBoundsByPeriod = isTimeViolatingFutureLimit({
time: browsingDate.add(1, "month").startOf("month").toDate(),
periodLimits,
});
return isOutOfBoundsByPeriod;
};
// Creates message to use in dialog explaining lack of availability based on period type
const useDescription = (noFutureAvailability: boolean, p: PeriodData) => {
const { t } = useLocale();
if (!noFutureAvailability) return "";
if (p.periodType === "ROLLING") {
const daysDescription = p.periodCountCalendarDays ? t("calendar_days") : t("business_days");
return t("no_availability_rolling", { days: `${p.periodDays} ${daysDescription}` });
}
if (p.periodType === "RANGE") {
return t("no_availability_range", { date: dayjs(p.periodEndDate).format("MMMM D YYYY") });
}
return "";
};
// This component is used to show a dialog when there is no availability in the selected month.
const NoAvailabilityDialog = ({
month,
nextMonthButton,
browsingDate,
periodData,
}: {
month: string | null;
nextMonthButton: () => void;
browsingDate: Dayjs;
periodData: PeriodData;
}) => {
const { t } = useLocale();
const [isOpenDialog, setIsOpenDialog] = useState(true);
const noFutureAvailability = useNoFutureAvailability(browsingDate, periodData);
const description = useDescription(noFutureAvailability, periodData);
const closeDialog = () => {
setIsOpenDialog(false);
};
return (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent
title={t("no_availability_in_month", { month: month })}
type="creation"
description={description}
preventCloseOnOutsideClick={false}>
<DialogFooter>
<DialogClose
color={noFutureAvailability ? "primary" : "secondary"}
onClick={closeDialog}
data-testid="close_dialog_button">
{t("close")}
</DialogClose>
{!noFutureAvailability && (
<Button
color="primary"
onClick={nextMonthButton}
data-testid="view_next_month"
EndIcon="arrow-right">
{t("view_next_month")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default NoAvailabilityDialog;
@@ -1,8 +1,9 @@
import { render } from "@testing-library/react";
import dayjs from "@calcom/dayjs";
import { PeriodType } from "@calcom/prisma/enums";
import { DatePicker } from "./DatePicker";
import { DatePicker } from "../DatePicker";
const noop = () => {
/* noop */
@@ -11,7 +12,20 @@ const 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 { getByTestId } = render(
<DatePicker
onChange={noop}
browsingDate={testDate}
locale="en"
periodData={{
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: false,
periodStartDate: null,
periodEndDate: null,
}}
/>
);
const selectedMonthLabel = getByTestId("selected-month-label");
await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM"));
@@ -0,0 +1,138 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { PeriodType } from "@calcom/prisma/enums";
import NoAvailabilityDialog from "../NoAvailabilityDialog";
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string, vars?: { [key: string]: string | number }) => {
if (key === "no_availability_in_month") return `No availability in ${vars?.month}`;
if (key === "no_availability_rolling")
return `Scheduling is only available up to ${vars?.days} in advance. Please check again soon.`;
if (key === "no_availability_range")
return `Scheduling ended on ${vars?.date}. Please check again soon.`;
if (key === "close") return "Close";
if (key === "view_next_month") return "View next month";
if (key === "calendar_days") return "calendar days";
return key;
},
}),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("NoAvailabilityOverlay", () => {
const defaultProps = {
month: "March",
nextMonthButton: vi.fn(),
browsingDate: dayjs(),
periodData: {
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: true,
periodStartDate: null,
periodEndDate: null,
},
};
test("Displays rolling period description and close button, when period type is ROLLING, and period ends before next month.", () => {
const periodDays = 5;
render(
<NoAvailabilityDialog
{...defaultProps}
browsingDate={dayjs().add(periodDays + 1, "day")}
periodData={{
...defaultProps.periodData,
periodType: PeriodType.ROLLING,
periodDays: periodDays,
}}
/>
);
expect(screen.getByRole("dialog")).toHaveTextContent(
`Scheduling is only available up to ${periodDays} calendar days in advance. Please check again soon.`
);
const nextMonthButton = screen.queryAllByTestId("view_next_month");
const closeButton = screen.getAllByTestId("close_dialog_button");
expect(nextMonthButton).toHaveLength(0);
expect(closeButton).toHaveLength(1);
});
test("Displays range period description when period type is RANGE and range ends before the next month", () => {
const startDate = dayjs().subtract(30, "days");
const endDate = dayjs();
render(
<NoAvailabilityDialog
{...defaultProps}
browsingDate={dayjs().add(40, "days")}
periodData={{
...defaultProps.periodData,
periodType: "RANGE",
periodStartDate: startDate.toDate(),
periodEndDate: endDate.toDate(),
}}
/>
);
expect(screen.getByRole("dialog")).toHaveTextContent(
`Scheduling ended on ${endDate.format("MMMM D YYYY")}. Please check again soon.`
);
const nextMonthButton = screen.queryAllByTestId("view_next_month");
const closeButton = screen.getAllByTestId("close_dialog_button");
expect(nextMonthButton).toHaveLength(0);
expect(closeButton).toHaveLength(1);
});
test("calls nextMonthButton when 'View next month' is clicked", () => {
render(<NoAvailabilityDialog {...defaultProps} />);
const nextButton = screen.getByText("View next month");
fireEvent.click(nextButton);
expect(defaultProps.nextMonthButton).toHaveBeenCalled();
});
test("Displays 'View next month' and close buttons, without description, when browsing current date with rolling 30-day period", () => {
render(
<NoAvailabilityDialog
{...defaultProps}
browsingDate={dayjs()}
periodData={{
...defaultProps.periodData,
periodType: "ROLLING",
periodDays: 30,
}}
/>
);
const description = screen.queryByTestId("dialog-subtitle");
const nextMonthButton = screen.getAllByTestId("view_next_month");
const closeButton = screen.getAllByTestId("close_dialog_button");
expect(description).not.toBeInTheDocument();
expect(nextMonthButton).toHaveLength(1);
expect(closeButton).toHaveLength(1);
});
test("Displays 'View next month' and 'close' button when browsing current date with range periodType starting 10 days in past and ending in a future month", () => {
render(
<NoAvailabilityDialog
{...defaultProps}
browsingDate={dayjs()}
periodData={{
...defaultProps.periodData,
periodType: "RANGE",
periodStartDate: dayjs().subtract(10, "days").toDate(),
periodEndDate: dayjs().add(40, "days").toDate(),
}}
/>
);
const description = screen.queryByTestId("dialog-subtitle");
const nextMonthButton = screen.getAllByTestId("view_next_month");
const closeButton = screen.getAllByTestId("close_dialog_button");
expect(description).not.toBeInTheDocument();
expect(nextMonthButton).toHaveLength(1);
expect(closeButton).toHaveLength(1);
});
});
@@ -142,6 +142,11 @@ const getPublicEventSelect = (fetchAllUsers: boolean) => {
timeZone: true,
},
},
periodType: true,
periodDays: true, // days if limiting future bookings
periodEndDate: true, //end date limit by range
periodStartDate: true, //start date limit by range
periodCountCalendarDays: true, // count calendar days? Or only business days based on periodDays
hidden: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
+7 -1
View File
@@ -6,7 +6,13 @@ const mockLogger = {
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
getSubLogger: vi.fn(),
getSubLogger: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
})),
};
vi.mock("@calcom/lib/logger", () => ({
+6
View File
@@ -1,6 +1,12 @@
import type { EventType } from "@calcom/prisma/client";
import type { NewCalendarEventType, AdditionalInformation } from "@calcom/types/Calendar";
import type { CrmData } from "./CrmService";
import type { VideoCallData } from "./VideoApiAdapter";
export type Event = AdditionalInformation | NewCalendarEventType | VideoCallData | CrmData;
export type PeriodData = Pick<
EventType,
"periodType" | "periodStartDate" | "periodEndDate" | "periodDays" | "periodCountCalendarDays"
>;
+2 -2
View File
@@ -126,12 +126,12 @@ export function DialogHeader(props: DialogHeaderProps) {
return (
<div className="mb-4">
<h3
<h2
data-testid="dialog-title"
className="leading-20 text-semibold text-emphasis font-cal mb-1 text-xl"
id="modal-title">
{props.title}
</h3>
</h2>
{props.subtitle && (
<p className="text-subtle text-sm" data-testid="dialog-subtitle">
{props.subtitle}