feat: date range picker (#14713)

* feat: add DateRangePicker.tsx and Calendar.tsx to match DS

* chore:cleanup

* tests: remove as these are duplicate tests from the library themselves

* fix: mobile sizes

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
sean-brydon
2024-04-26 13:38:42 +00:00
committed by GitHub
co-authored by Udit Takkar
parent ceb555460e
commit 386006fad0
8 changed files with 150 additions and 123 deletions
@@ -474,8 +474,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
name="periodDates"
render={({ field: { onChange } }) => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
dates={{
startDate: formMethods.getValues("periodDates").startDate,
endDate: formMethods.getValues("periodDates").endDate,
}}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
onChange({
@@ -99,10 +99,13 @@ const TravelScheduleModal = ({
<>
<Label className="mt-2">{t("time_range")}</Label>
<DateRangePicker
startDate={startDate}
endDate={endDate ?? startDate}
dates={{
startDate,
endDate: endDate ?? startDate,
}}
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
setStartDate(newStartDate);
// If newStartDate does become undefined - we resort back to to-todays date
setStartDate(newStartDate ?? new Date());
setEndDate(newEndDate);
setErrorMessage("");
}}
@@ -74,7 +74,7 @@ const CreateOutOfOfficeEntryModal = ({
label: member.name || "",
})) || [];
const { handleSubmit, setValue, getValues, control, register } = useForm<BookingRedirectForm>({
const { handleSubmit, setValue, control, register } = useForm<BookingRedirectForm>({
defaultValues: {
dateRange: {
startDate: dateRange.startDate,
@@ -132,15 +132,11 @@ const CreateOutOfOfficeEntryModal = ({
name="dateRange"
control={control}
defaultValue={dateRange}
render={() => (
render={({ field: { onChange, value } }) => (
<DateRangePicker
startDate={getValues("dateRange").startDate}
endDate={getValues("dateRange").endDate}
onDatesChange={({ startDate, endDate }) => {
setValue("dateRange", {
startDate,
endDate,
});
dates={{ startDate: value.startDate, endDate: value.endDate }}
onDatesChange={(values) => {
onChange(values);
}}
/>
)}
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import dayjs from "@calcom/dayjs";
import { classNames as cn } from "@calcom/lib";
import { Icon } from "../../../index";
import { buttonClasses } from "../../button/Button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
fromDate={new Date()}
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex pt-1 relative items-center justify-between",
caption_label: "text-sm font-medium",
nav: "flex items-center",
head: "",
head_row: "flex w-full items-center justify-between",
head_cell: "w-8 md:w-11 h-8 text-sm font-medium text-default",
nav_button: cn(buttonClasses({ color: "minimal", variant: "icon" })),
table: "w-full border-collapse space-y-1",
row: "flex w-full mt-2 gap-0.5",
cell: "h-8 w-8 md:h-11 md:w-11 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonClasses({ color: "minimal" }),
"w-8 h-8 md:h-11 md:w-11 p-0 text-sm font-medium aria-selected:opacity-100 inline-flex items-center justify-center"
),
day_range_end: "day-range-end",
day_selected: "bg-inverted text-inverted",
day_today: "",
day_outside: "",
day_disabled: "text-muted opacity-50",
day_range_middle: "aria-selected:bg-emphasis aria-selected:text-emphasis",
day_hidden: "invisible",
...classNames,
}}
components={{
CaptionLabel: (capLabelProps) => (
<div className="px-2">
<span className="text-emphasis leadning-none font-semibold">
{dayjs(capLabelProps.displayMonth).format("MMMM")}{" "}
</span>
<span className="text-subtle font-medium leading-none">
{dayjs(capLabelProps.displayMonth).format("YYYY")}
</span>
</div>
),
IconLeft: () => <Icon name="chevron-left" className="h-4 w-4 stroke-2" />,
IconRight: () => <Icon name="chevron-right" className="h-4 w-4 stroke-2" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
@@ -1,35 +1,70 @@
// @see: https://github.com/wojtekmaj/react-daterange-picker/issues/91
import "@wojtekmaj/react-daterange-picker/dist/DateRangePicker.css";
import PrimitiveDateRangePicker from "@wojtekmaj/react-daterange-picker/dist/entry.nostyle";
"use client";
import { Icon } from "../../..";
import "./styles.css";
import { format } from "date-fns";
import * as React from "react";
import type { DateRange } from "react-day-picker";
type Props = {
disabled?: boolean | undefined;
startDate: Date;
endDate: Date;
onDatesChange?: ((arg: { startDate: Date; endDate: Date }) => void) | undefined;
import { classNames as cn } from "@calcom/lib";
import { Button } from "../../button";
import { Popover, PopoverContent, PopoverTrigger } from "../../popover";
import { Calendar } from "./Calendar";
type DatePickerWithRangeProps = {
dates: { startDate: Date; endDate?: Date };
onDatesChange: ({ startDate, endDate }: { startDate?: Date; endDate?: Date }) => void;
disabled?: boolean;
};
const DateRangePicker = ({ disabled, startDate, endDate, onDatesChange }: Props) => {
export function DatePickerWithRange({
className,
dates,
onDatesChange,
disabled,
}: React.HTMLAttributes<HTMLDivElement> & DatePickerWithRangeProps) {
// Even though this is uncontrolled we need to do a bit of logic to improve the UX when selecting dates
function _onDatesChange(onChangeValues: DateRange | undefined) {
if (onChangeValues?.from && !onChangeValues?.to) {
onDatesChange({ startDate: onChangeValues.from, endDate: onChangeValues.from });
} else {
onDatesChange({ startDate: onChangeValues?.from, endDate: onChangeValues?.to });
}
}
return (
<>
<PrimitiveDateRangePicker
disabled={disabled || false}
className="border-default rounded-sm text-sm"
clearIcon={null}
calendarIcon={<Icon name="calendar" className="text-subtle h-4 w-4" />}
rangeDivider={<Icon name="arrow-right" className="text-muted h-4 w-4 ltr:mr-2 rtl:ml-2" />}
value={[startDate, endDate]}
onChange={([startDate, endDate]: [Date, Date]) => {
if (typeof onDatesChange === "function") onDatesChange({ startDate, endDate });
}}
nextLabel={<Icon name="chevron-right" className="text-subtle h-4 w-4" />}
prevLabel={<Icon name="chevron-left" className="text-subtle h-4 w-4" />}
/>
</>
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
color="secondary"
EndIcon="calendar"
className={cn("justify-between text-left font-normal", !dates && "text-subtle")}>
{dates?.startDate ? (
dates?.endDate ? (
<>
{format(dates.startDate, "LLL dd, y")} - {format(dates.endDate, "LLL dd, y")}
</>
) : (
format(dates.startDate, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={dates?.startDate}
selected={{ from: dates?.startDate, to: dates?.endDate }}
onSelect={(values) => _onDatesChange(values)}
numberOfMonths={1}
disabled={disabled}
/>
</PopoverContent>
</Popover>
</div>
);
};
export default DateRangePicker;
}
@@ -1,77 +0,0 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, fireEvent } from "@testing-library/react";
import { format } from "date-fns";
import { vi } from "vitest";
import DateRangePicker from "./DateRangePicker";
describe("Tests for DateRangePicker component", () => {
const startDate = new Date("2023-07-15");
const endDate = new Date("2023-07-20");
test("Should display the selected start and end dates", () => {
const { getByDisplayValue } = render(<DateRangePicker startDate={startDate} endDate={endDate} />);
const expectedStartDate = format(new Date(startDate), "yyyy-MM-dd");
const expectedEndDate = format(new Date(endDate), "yyyy-MM-dd");
expect(getByDisplayValue(expectedStartDate)).toBeInTheDocument();
expect(getByDisplayValue(expectedEndDate)).toBeInTheDocument();
});
test("Should disable the DateRangePicker when disabled prop is true", () => {
const { getByDisplayValue } = render(
<DateRangePicker startDate={startDate} endDate={endDate} disabled />
);
const startDateInput = getByDisplayValue(format(startDate, "yyyy-MM-dd")) as HTMLInputElement;
const endDateInput = getByDisplayValue(format(endDate, "yyyy-MM-dd")) as HTMLInputElement;
expect(startDateInput).toBeDisabled();
expect(endDateInput).toBeDisabled();
});
test("Should call the onDatesChange callback when the selected dates change", () => {
const startDate = new Date("2023-07-14");
const endDate = new Date("2023-07-19");
const mockOnDatesChange = vi.fn();
const { getByDisplayValue } = render(
<DateRangePicker startDate={startDate} endDate={endDate} onDatesChange={mockOnDatesChange} />
);
const startDateInput = getByDisplayValue(format(startDate, "yyyy-MM-dd")) as HTMLInputElement;
const endDateInput = getByDisplayValue(format(endDate, "yyyy-MM-dd")) as HTMLInputElement;
const newStartDate = new Date(startDate);
newStartDate.setDate(newStartDate.getDate() + 1);
const newEndDate = new Date(endDate);
newEndDate.setDate(newEndDate.getDate() + 1);
const eventStartDate = { target: { value: format(newStartDate, "yyyy-MM-dd") } };
const eventEndDate = { target: { value: format(newEndDate, "yyyy-MM-dd") } };
fireEvent.change(startDateInput, eventStartDate);
fireEvent.change(endDateInput, eventEndDate);
const formattedStartDate = mockOnDatesChange.mock.calls[0][0].startDate;
const formattedEndDate = mockOnDatesChange.mock.calls[1][0].endDate;
expect(mockOnDatesChange).toHaveBeenCalled();
expect(mockOnDatesChange).toHaveBeenCalledWith({
startDate: expect.any(Date),
endDate: expect.any(Date),
});
expect(formattedStartDate.getDate()).toEqual(newStartDate.getDate());
expect(formattedStartDate.getMonth()).toEqual(newStartDate.getMonth());
expect(formattedStartDate.getFullYear()).toEqual(newStartDate.getFullYear());
expect(formattedEndDate.getDate()).toEqual(newEndDate.getDate());
expect(formattedEndDate.getMonth()).toEqual(newEndDate.getMonth());
expect(formattedEndDate.getFullYear()).toEqual(newEndDate.getFullYear());
});
});
HTMLCanvasElement.prototype.getContext = vi.fn() as never;
@@ -1,5 +1,5 @@
import dynamic from "next/dynamic";
export const DateRangePickerLazy = dynamic(
() => import("./DateRangePicker")
) as unknown as typeof import("./DateRangePicker").default;
export const DateRangePickerLazy = dynamic(() =>
import("./DateRangePicker").then((mod) => mod.DatePickerWithRange)
);
+2
View File
@@ -40,12 +40,14 @@
"class-variance-authority": "^0.4.0",
"cmdk": "^0.2.0",
"cmk": "^0.1.1",
"date-fns": "^3.6.0",
"downshift": "^6.1.9",
"lucide-react": "^0.364.0",
"next": "^13.5.4",
"next-seo": "^6.0.0",
"react": "^18.2.0",
"react-colorful": "^5.6.0",
"react-day-picker": "^8.10.1",
"react-feather": "^2.0.10",
"react-hook-form": "^7.43.3",
"react-select": "^5.7.0",