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:
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
+16
-2
@@ -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,
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
Vendored
+6
@@ -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"
|
||||
>;
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user