From 402fdffa988c4cd488ecac70d6d3a71fa70c2670 Mon Sep 17 00:00:00 2001 From: Josh Slaughter <8338893+jdslaugh@users.noreply.github.com> Date: Mon, 24 Jul 2023 09:29:37 -0700 Subject: [PATCH] frontend: Add Time picker component (#2743) --- .../packages/core/src/Input/date-time.tsx | 4 +- frontend/packages/core/src/Input/index.tsx | 1 + .../src/Input/stories/date-time.stories.tsx | 20 ++++-- .../src/Input/stories/time-picker.stories.tsx | 45 +++++++++++++ .../core/src/Input/tests/time-picker.test.tsx | 65 +++++++++++++++++++ .../packages/core/src/Input/time-picker.tsx | 41 ++++++++++++ 6 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 frontend/packages/core/src/Input/stories/time-picker.stories.tsx create mode 100644 frontend/packages/core/src/Input/tests/time-picker.test.tsx create mode 100644 frontend/packages/core/src/Input/time-picker.tsx diff --git a/frontend/packages/core/src/Input/date-time.tsx b/frontend/packages/core/src/Input/date-time.tsx index 73a4305d85..3c48ed794b 100644 --- a/frontend/packages/core/src/Input/date-time.tsx +++ b/frontend/packages/core/src/Input/date-time.tsx @@ -25,8 +25,8 @@ const DateTimePicker = ({ onChange, ...props }: DateTimePickerProps) => ( } - onChange={(value: Dayjs) => { - if (value.isValid()) { + onChange={(value: Dayjs | null) => { + if (value && value.isValid()) { onChange(value.toDate()); } }} diff --git a/frontend/packages/core/src/Input/index.tsx b/frontend/packages/core/src/Input/index.tsx index ad8789f7a4..661792043b 100644 --- a/frontend/packages/core/src/Input/index.tsx +++ b/frontend/packages/core/src/Input/index.tsx @@ -1,5 +1,6 @@ export { Checkbox, CheckboxPanel } from "./checkbox"; export { default as DateTimePicker } from "./date-time"; +export { default as TimePicker } from "./time-picker"; export { Form, FormRow } from "./form"; export { default as Radio } from "./radio"; export { default as RadioGroup } from "./radio-group"; diff --git a/frontend/packages/core/src/Input/stories/date-time.stories.tsx b/frontend/packages/core/src/Input/stories/date-time.stories.tsx index 3c771a6ade..f7674855c6 100644 --- a/frontend/packages/core/src/Input/stories/date-time.stories.tsx +++ b/frontend/packages/core/src/Input/stories/date-time.stories.tsx @@ -16,15 +16,27 @@ export default { const Template = (props: DateTimePickerProps) => ; -export const Primary = Template.bind({}); -Primary.args = { +export const PrimaryDemo = ({ ...props }) => { + const [dateValue, setDateValue] = React.useState(props.value); + + return ( + { + setDateValue(newValue as Date); + }} + value={dateValue ?? props.value} + /> + ); +}; + +PrimaryDemo.args = { label: "My Label", - onChange: () => {}, value: new Date(), } as DateTimePickerProps; export const Disabled = Template.bind({}); Disabled.args = { - ...Primary.args, + ...PrimaryDemo.args, disabled: true, } as DateTimePickerProps; diff --git a/frontend/packages/core/src/Input/stories/time-picker.stories.tsx b/frontend/packages/core/src/Input/stories/time-picker.stories.tsx new file mode 100644 index 0000000000..6675ce1ae7 --- /dev/null +++ b/frontend/packages/core/src/Input/stories/time-picker.stories.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import type { Meta } from "@storybook/react"; + +import type { TimePickerProps } from "../time-picker"; +import TimePicker from "../time-picker"; + +export default { + title: "Core/Input/TimePicker", + component: TimePicker, + argTypes: { + label: { + control: "text", + }, + value: { + control: "date", + }, + }, +} as Meta; + +const Template = (props: TimePickerProps) => ; + +export const PrimaryDemo = ({ ...props }) => { + const [timeValue, setTimeValue] = React.useState(props.value); + + return ( + { + setTimeValue(newValue as Date); + }} + value={timeValue ?? props.value} + /> + ); +}; + +PrimaryDemo.args = { + label: "My Label", + value: new Date(), +} as TimePickerProps; + +export const Disabled = Template.bind({}); +Disabled.args = { + ...PrimaryDemo.args, + disabled: true, +} as TimePickerProps; diff --git a/frontend/packages/core/src/Input/tests/time-picker.test.tsx b/frontend/packages/core/src/Input/tests/time-picker.test.tsx new file mode 100644 index 0000000000..90e8d2c6c0 --- /dev/null +++ b/frontend/packages/core/src/Input/tests/time-picker.test.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import TimePicker from "../time-picker"; + +afterEach(() => { + jest.resetAllMocks(); +}); + +const onChange = jest.fn(); +test("has padding", () => { + const { container } = render(); + + expect(container.querySelectorAll(".MuiInputBase-adornedEnd")).toHaveLength(1); + expect(container.querySelector(".MuiInputBase-adornedEnd")).toHaveStyle({ + "padding-right": "14px", + }); +}); + +test("onChange is called when valid value", () => { + render(); + + expect(screen.getByPlaceholderText("hh:mm (a|p)m")).toBeVisible(); + fireEvent.change(screen.getByPlaceholderText("hh:mm (a|p)m"), { + target: { value: "02:55 AM" }, + }); + expect(onChange).toHaveBeenCalled(); +}); + +test("onChange is not called when invalid value", () => { + render(); + + expect(screen.getByPlaceholderText("hh:mm (a|p)m")).toBeVisible(); + fireEvent.change(screen.getByPlaceholderText("hh:mm (a|p)m"), { + target: { value: "invalid" }, + }); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("sets passed value correctly", () => { + const date = new Date(); + const formattedTime = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + const formattedDate = `${formattedTime}`; + render(); + + expect(screen.getByPlaceholderText("hh:mm (a|p)m")).toHaveValue(formattedDate); +}); + +test("displays label correctly", () => { + const label = "testing"; + render(); + + expect(screen.getByLabelText(label)).toBeVisible(); +}); + +test("is disabled", () => { + render(); + + expect(screen.getByPlaceholderText("hh:mm (a|p)m")).toBeDisabled(); +}); diff --git a/frontend/packages/core/src/Input/time-picker.tsx b/frontend/packages/core/src/Input/time-picker.tsx new file mode 100644 index 0000000000..4c06c454e2 --- /dev/null +++ b/frontend/packages/core/src/Input/time-picker.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import type { TimePickerProps as MuiTimePickerProps } from "@mui/lab"; +import { TimePicker as MuiTimePicker } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import type { Dayjs } from "dayjs"; + +import styled from "../styled"; + +import { TextField } from "./text-field"; + +const PaddedTextField = styled(TextField)({ + // This is required as TextField intentionally unsets the right padding for + // end adornment styles since material introduced it in v5 + // Clutch has TextFields with end adornments that are end aligned (e.g. resolvers). + ".MuiInputBase-adornedEnd": { + paddingRight: "14px", + }, +}); + +export interface TimePickerProps + extends Pick< + MuiTimePickerProps, + "disabled" | "value" | "onChange" | "label" | "PaperProps" | "PopperProps" + > {} + +const TimePicker = ({ onChange, ...props }: TimePickerProps) => ( + + } + onChange={(value: Dayjs | null) => { + if (value && value.isValid()) { + onChange(value.toDate()); + } + }} + {...props} + /> + +); + +export default TimePicker;