diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 5d1ecc39d8..42717f6aa9 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/bookings/Booker/__tests__/Booker.test.tsx b/packages/features/bookings/Booker/__tests__/Booker.test.tsx index 60f3db2251..7912574b68 100644 --- a/packages/features/bookings/Booker/__tests__/Booker.test.tsx +++ b/packages/features/bookings/Booker/__tests__/Booker.test.tsx @@ -63,6 +63,12 @@ const mockEvent = { isPending: false, }; +vi.mock("../../../calendars/NoAvailabilityDialog", () => ({ + default: () => { + return null; + }, +})); + const mockSchedule = { data: { slots: { diff --git a/packages/features/bookings/Booker/components/DatePicker.tsx b/packages/features/bookings/Booker/components/DatePicker.tsx index 3317f1c75d..76726ca1b4 100644 --- a/packages/features/bookings/Booker/components/DatePicker.tsx +++ b/packages/features/bookings/Booker/components/DatePicker.tsx @@ -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[] } | null; + data?: { + subsetOfUsers: Pick[]; + 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 ( ); }; diff --git a/packages/features/calendars/DatePicker.tsx b/packages/features/calendars/DatePicker.tsx index 7173b0a369..4a4049f79e 100644 --- a/packages/features/calendars/DatePicker.tsx +++ b/packages/features/calendars/DatePicker.tsx @@ -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 ( -
-

{t("no_availability_in_month", { month: month })}

- -
- ); -}; - const Days = ({ minDate, excludedDates = [], @@ -143,6 +128,7 @@ const Days = ({ slots, customClassName, isBookingInPast, + periodData, ...props }: Omit & { DayComponent?: React.FC>; @@ -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 = ({ )} ))} - {!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && ( - + )} ); @@ -299,6 +290,13 @@ const DatePicker = ({ slots, customClassNames, includedDates, + periodData = { + periodStartDate: null, + periodEndDate: null, + periodCountCalendarDays: null, + periodDays: null, + periodType: "UNLIMITED", + }, ...passThroughProps }: DatePickerProps & Partial> & { @@ -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} /> diff --git a/packages/features/calendars/NoAvailabilityDialog.tsx b/packages/features/calendars/NoAvailabilityDialog.tsx new file mode 100644 index 0000000000..ef2053f74a --- /dev/null +++ b/packages/features/calendars/NoAvailabilityDialog.tsx @@ -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 ( + { + setIsOpenDialog(open); + }}> + + + + {t("close")} + + {!noFutureAvailability && ( + + )} + + + + ); +}; + +export default NoAvailabilityDialog; diff --git a/packages/features/calendars/DatePicker.test.tsx b/packages/features/calendars/__tests__/DatePicker.test.tsx similarity index 75% rename from packages/features/calendars/DatePicker.test.tsx rename to packages/features/calendars/__tests__/DatePicker.test.tsx index 07a1cffdfc..634064a56e 100644 --- a/packages/features/calendars/DatePicker.test.tsx +++ b/packages/features/calendars/__tests__/DatePicker.test.tsx @@ -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(); + const { getByTestId } = render( + + ); const selectedMonthLabel = getByTestId("selected-month-label"); await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM")); diff --git a/packages/features/calendars/__tests__/NoAvailabilityDialog.test.tsx b/packages/features/calendars/__tests__/NoAvailabilityDialog.test.tsx new file mode 100644 index 0000000000..4d047d7708 --- /dev/null +++ b/packages/features/calendars/__tests__/NoAvailabilityDialog.test.tsx @@ -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( + + ); + + 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( + + ); + 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(); + 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( + + ); + 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( + + ); + 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); + }); +}); diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 79a88c61ee..68c7a1139e 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -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, diff --git a/packages/lib/__mocks__/logger.ts b/packages/lib/__mocks__/logger.ts index 2c5decf19f..976bcafbe4 100644 --- a/packages/lib/__mocks__/logger.ts +++ b/packages/lib/__mocks__/logger.ts @@ -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", () => ({ diff --git a/packages/types/Event.d.ts b/packages/types/Event.d.ts index 4ab3f0d6dc..42c20bd664 100644 --- a/packages/types/Event.d.ts +++ b/packages/types/Event.d.ts @@ -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" +>; diff --git a/packages/ui/components/dialog/Dialog.tsx b/packages/ui/components/dialog/Dialog.tsx index dbbd991b6b..17c56fbe4e 100644 --- a/packages/ui/components/dialog/Dialog.tsx +++ b/packages/ui/components/dialog/Dialog.tsx @@ -126,12 +126,12 @@ export function DialogHeader(props: DialogHeaderProps) { return (
- + {props.subtitle && (

{props.subtitle}