Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle invalid value prop in DateTimePicker #2074

Merged
merged 15 commits into from
Feb 5, 2024
4 changes: 2 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,8 @@ export type DatePickerProps = {
};

export type DateTimePickerProps = {
value: any;
defaultValue?: any;
value?: string;
defaultValue?: string;
className?: string;
label?: string;
size?: "small" | "medium" | "large";
Expand Down
63 changes: 39 additions & 24 deletions src/components/DateTimePicker/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react";
import classnames from "classnames";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { isPresent } from "neetocist";
import PropTypes from "prop-types";

import { TimePickerInput, DatePicker, Label } from "components";
Expand All @@ -12,6 +13,9 @@ import { hyphenize, noop } from "utils";
const INPUT_SIZES = { small: "small", medium: "medium", large: "large" };
dayjs.extend(customParseFormat);

const DATE_FORMAT = "YYYY-MM-DD";
const TIME_FORMAT = "HH:mm";

const DateTimePicker = ({
className = "",
label = "",
Expand All @@ -31,38 +35,50 @@ const DateTimePicker = ({
datePickerProps,
timePickerProps,
}) => {
const [open, setOpen] = React.useState(false);
const [time, setTime] = useState(value);

const [open, setOpen] = useState(datePickerProps?.open);
const [date, setDate] = useState();
const [time, setTime] = useState();
const [changedField, setChangedField] = useState();
const timeRef = React.useRef(null);
const defaultId = useId(id);
const errorId = `error_${defaultId}`;

useEffect(() => {
if (dayjs(value).isSame(time)) return;
setTime(value);
}, [value]);
const inputValue = value || defaultValue;
if (isPresent(inputValue) && dayjs(inputValue).isValid()) {
const dateTime = dayjs.isDayjs(inputValue)
? inputValue
: dayjs(inputValue);
setDate(dateTime);
setTime(dateTime);
}
}, [value, defaultValue]);

useEffect(() => {
if (!isPresent(changedField)) return;

const handleDateChange = date => {
if (!time) setTime(date);
if (isPresent(date) && isPresent(time)) {
onChange(
dayjs(`${date.format(DATE_FORMAT)} ${time.format(TIME_FORMAT)}`),
changedField
);
} else {
onChange(null, changedField);
}
}, [date, time, changedField]);

const handleDateChange = newDate => {
setOpen(false);
onChange(date, "date");
setChangedField("date");
setDate(newDate);
timeRef.current
?.querySelector(".react-time-picker__inputGroup__hour")
?.focus();
};

const handleTimeChange = (_, timeValue) => {
if (!timeValue) {
setTime(null);

return;
}
const currentDate = dayjs(value);
const dateTime = dayjs(`${currentDate?.format("YYYY-MM-DD")}
${timeValue || ""}`);
setTime(dateTime);
onChange(dateTime, "time");
const handleTimeChange = newTime => {
setChangedField("time");
setTime(newTime.isValid() ? newTime : null);
};

return (
Expand All @@ -72,18 +88,17 @@ const DateTimePicker = ({
<DatePicker
{...{
dateFormat,
defaultValue,
dropdownClassName,
nakedInput,
open,
popupClassName,
size,
value,
}}
error={!!error}
picker="date"
showTime={false}
type="date"
value={date}
onBlur={() => setOpen(false)}
onChange={handleDateChange}
onFocus={() => setOpen(true)}
Expand Down Expand Up @@ -158,11 +173,11 @@ DateTimePicker.propTypes = {
/**
* To specify the values to be displayed inside the DatePicker.
*/
value: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
value: PropTypes.string,
/**
* To specify the default values to be displayed inside the DatePicker.
*/
defaultValue: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
defaultValue: PropTypes.string,
/**
* The callback function that will be triggered when time picker loses focus (onBlur event).
*/
Expand Down
2 changes: 1 addition & 1 deletion src/components/TimePickerInput/HoverIcon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ const HoverIcon = ({ time = false }) => {
/**
* time prop is required to find the feild is filled or not
*/
HoverIcon.propTypes = { time: PropTypes.string.isRequired };
HoverIcon.propTypes = { time: PropTypes.bool.isRequired };

export default HoverIcon;
26 changes: 18 additions & 8 deletions src/components/TimePickerInput/index.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import React, { forwardRef } from "react";
import React, { forwardRef, useMemo } from "react";

import classnames from "classnames";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { isPresent } from "neetocist";
import PropTypes from "prop-types";
import TimePicker from "react-time-picker";

import Label from "components/Label";
import { useId } from "hooks";
import { convertToDayjsObjects, hyphenize, noop } from "utils";
import { hyphenize, noop } from "utils";

import HoverIcon from "./HoverIcon";

dayjs.extend(customParseFormat);

const INPUT_SIZES = { small: "small", medium: "medium", large: "large" };

const FORMAT = "HH:mm";

const TimePickerInput = forwardRef(
(
{
Expand All @@ -25,34 +28,41 @@ const TimePickerInput = forwardRef(
size = INPUT_SIZES.medium,
nakedInput = false,
required = false,
value,
value: inputValue,
onChange,
error = "",
onBlur = noop,
...otherProps
},
ref
) => {
const value = useMemo(() => {
if (isPresent(inputValue) && dayjs(inputValue).isValid()) {
return inputValue.format(FORMAT);
}

return null;
}, [inputValue]);

const id = useId(otherProps.id);
const errorId = `error_${id}`;

const handleChange = value => {
const time = dayjs(value, "HH:mm");
onChange(time, value);
const handleChange = newValue => {
const time = dayjs(newValue, FORMAT);
onChange(time, newValue);
};

return (
<div {...{ ref }} className="neeto-ui-input__wrapper">
{label && <Label {...{ required, ...labelProps }}>{label}</Label>}
<TimePicker
{...{ id }}
{...{ id, value }}
disableClock
clearIcon={<HoverIcon time={!!value} />}
format="hh:mm a"
hourPlaceholder="HH"
minutePlaceholder="mm"
secondAriaLabel="ss"
value={convertToDayjsObjects(value)?.toDate()}
className={classnames("neeto-ui-time-picker", [className], {
"neeto-ui-time-picker--small": size === "small",
"neeto-ui-time-picker--medium": size === "medium",
Expand Down
90 changes: 90 additions & 0 deletions tests/DateTimePicker.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from "react";

import { fireEvent, render, screen } from "@testing-library/react";
import dayjs from "dayjs";

import DateTimePicker from "components/DateTimePicker";

const theDate = dayjs("2024-12-24 12:30");
const anotherDate = theDate.add(1, "day");
const anotherTime = theDate.subtract(1, "hour").subtract(15, "minute");

const DATE_DISPLAY_FORMAT = "DD/MM/YYYY";
const TIME_FORMAT = "HH:mm";

const simpleDayJsObj = obj => ({
$y: obj.$y,
$M: obj.$M,
$D: obj.$D,
$W: obj.$W,
$H: obj.$H,
$m: obj.$m,
$s: obj.$s,
$ms: obj.$ms,
});

describe("DateTimePicker", () => {
it("should render without error", () => {
render(<DateTimePicker defaultValue={theDate} />);
expect(screen.getByRole("textbox")).toHaveValue(
theDate.format(DATE_DISPLAY_FORMAT)
);

expect(
screen.getByDisplayValue(theDate.format(TIME_FORMAT))
).toBeInTheDocument();
});

it("onChange should be called on date change", () => {
const onChangeMock = jest.fn();
render(
<DateTimePicker
datePickerProps={{ open: true }}
value={theDate}
onChange={onChangeMock}
/>
);
fireEvent.click(screen.getByText(anotherDate.get("D")));
expect(onChangeMock).toHaveBeenCalledWith(
expect.objectContaining(simpleDayJsObj(anotherDate)),
"date"
);
});

it("onChange should be called on time change", () => {
const onChangeMock = jest.fn();
render(<DateTimePicker value={theDate} onChange={onChangeMock} />);
const timePickerInput = screen.getByDisplayValue(
theDate.format(TIME_FORMAT)
);
fireEvent.change(timePickerInput, { target: { value: "11:15" } });

expect(onChangeMock).toHaveBeenCalledWith(
expect.objectContaining(simpleDayJsObj(anotherTime)),
"time"
);
});

it("onChange is called with null if time is not set", () => {
const onChangeMock = jest.fn();
const { container } = render(
<DateTimePicker value={theDate} onChange={onChangeMock} />
);

const timeInput = container.querySelector(
'input[name="time"][type="time"]'
);
fireEvent.change(timeInput, { target: { value: "" } }); // remove time
expect(onChangeMock).toHaveBeenCalledWith(null, "time");
});

it("onChange is called with null if date is not set", () => {
const onChangeMock = jest.fn();
const { container } = render(<DateTimePicker onChange={onChangeMock} />);
const timeInput = container.querySelector(
'input[name="time"][type="time"]'
);
fireEvent.change(timeInput, { target: { value: "12:30" } }); // set only time
expect(onChangeMock).toHaveBeenCalledWith(null, "time");
});
});