From 518d85d037718eeed7c217a6f3830aa990ec1e76 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:20:27 -0600 Subject: [PATCH] feat: [M3-8611]- New DatePicker Component (#11151) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * chore: [M3-8662] - Update Github Actions actions (#11009) * update actions * add changeset --------- Co-authored-by: Banks Nussman * Basci date picker component * Test coverage for date picker component * DatePicker Stories * Custom DateTimePicker component * Reusable TimeZone Select Component * Create custom DateTimeRangePicker component * Storybook for DateTimePicker * Fix tests and remove console warnings * changeset * Update packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * Adjust styles for DatePicker * Adjust styles for DateTimePicker * update imports * Render time and timezone conditionally in DateTimePicker component * Move DatePicker to UI package * Add DatePicker dependencies * Code cleanup * PR feedback * code cleanup * Move DatePicker back to src/components * Reverting changes * Code cleanup * Adjust broken tests * Update TimeZoneSelect.tsx * Code cleanup * Add validation for start date agains end date. * Adjust styles for TimePicker component. * PR feedback - @jaalah-akamai * allow error messages from props. * Update storybook components with args * Update props * PR feedback - @hana-akamai --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --- .../pr-11151-added-1731944151381.md | 5 + .../DatePicker/DatePicker.stories.tsx | 124 ++++++++ .../components/DatePicker/DatePicker.test.tsx | 80 ++++++ .../src/components/DatePicker/DatePicker.tsx | 91 ++++++ .../DatePicker/DateTimePicker.stories.tsx | 145 ++++++++++ .../DatePicker/DateTimePicker.test.tsx | 143 ++++++++++ .../components/DatePicker/DateTimePicker.tsx | 268 ++++++++++++++++++ .../DateTimeRangePicker.stories.tsx | 117 ++++++++ .../DatePicker/DateTimeRangePicker.test.tsx | 105 +++++++ .../DatePicker/DateTimeRangePicker.tsx | 144 ++++++++++ .../components/DatePicker/TimeZoneSelect.tsx | 64 +++++ packages/ui/package.json | 2 +- 12 files changed, 1287 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11151-added-1731944151381.md create mode 100644 packages/manager/src/components/DatePicker/DatePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DatePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DatePicker.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx create mode 100644 packages/manager/src/components/DatePicker/TimeZoneSelect.tsx diff --git a/packages/manager/.changeset/pr-11151-added-1731944151381.md b/packages/manager/.changeset/pr-11151-added-1731944151381.md new file mode 100644 index 00000000000..1a64466eea4 --- /dev/null +++ b/packages/manager/.changeset/pr-11151-added-1731944151381.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +New DatePicker Component ([#11151](https://github.com/linode/manager/pull/11151)) diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..640fae75510 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,124 @@ +import { action } from '@storybook/addon-actions'; +import * as React from 'react'; + +import { DatePicker } from './DatePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; +import type { DateTime } from 'luxon'; + +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + errorText: { + control: 'text', + description: 'Error text to display below the input', + }, + format: { + control: 'text', + description: 'Format of the date when rendered in the input field', + }, + helperText: { + control: 'text', + description: 'Helper text to display below the input', + }, + label: { + control: 'text', + description: 'Label to display for the date picker input', + }, + onChange: { + action: 'date-changed', + description: 'Callback function fired when the value changes', + }, + placeholder: { + control: 'text', + description: 'Placeholder text for the date picker input', + }, + textFieldProps: { + control: 'object', + description: + 'Additional props to pass to the underlying TextField component', + }, + value: { + control: 'date', + description: 'The currently selected date', + }, + }, + args: { + errorText: '', + format: 'yyyy-MM-dd', + label: 'Select a Date', + onChange: action('date-changed'), + placeholder: 'yyyy-MM-dd', + textFieldProps: { label: 'Select a Date' }, + value: null, + }, +}; + +export const ControlledExample: Story = { + args: { + errorText: '', + format: 'yyyy-MM-dd', + helperText: 'This is a controlled DatePicker', + label: 'Controlled Date Picker', + placeholder: 'yyyy-MM-dd', + value: null, + }, + render: (args) => { + const ControlledDatePicker = () => { + const [selectedDate, setSelectedDate] = React.useState(); + + const handleChange = (newDate: DateTime | null) => { + setSelectedDate(newDate); + action('Controlled date change')(newDate?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +const meta: Meta = { + argTypes: { + errorText: { + control: 'text', + }, + format: { + control: 'text', + }, + helperText: { + control: 'text', + }, + label: { + control: 'text', + }, + onChange: { + action: 'date-changed', + }, + placeholder: { + control: 'text', + }, + textFieldProps: { + control: 'object', + }, + value: { + control: 'date', + }, + }, + args: { + errorText: '', + format: 'yyyy-MM-dd', + helperText: '', + label: 'Select a Date', + placeholder: 'yyyy-MM-dd', + value: null, + }, + component: DatePicker, + title: 'Components/DatePicker/DatePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DatePicker.test.tsx b/packages/manager/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 00000000000..e051d160ec5 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatePicker } from './DatePicker'; + +import type { DatePickerProps } from './DatePicker'; + +const props: DatePickerProps = { + onChange: vi.fn(), + placeholder: 'Pick a date', + textFieldProps: { errorText: 'Invalid date', label: 'Select a date' }, + value: null, +}; + +describe('DatePicker', () => { + it('should render the DatePicker component', () => { + renderWithTheme(); + const DatePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + + expect(DatePickerField).toBeVisible(); + }); + + it('should handle value changes', async () => { + renderWithTheme(); + + const calendarButton = screen.getByRole('button', { name: 'Choose date' }); + + // Click the calendar button to open the date picker + await userEvent.click(calendarButton); + + // Find a date button to click (e.g., the 15th of the month) + const dateToSelect = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateToSelect); + + // Check if onChange was called after selecting a date + expect(props.onChange).toHaveBeenCalled(); + }); + + it('should display the error text when provided', () => { + renderWithTheme(); + const errorMessage = screen.getByText('Invalid date'); + expect(errorMessage).toBeVisible(); + }); + + it('should display the helper text when provided', () => { + renderWithTheme(); + const helperText = screen.getByText('Choose a valid date'); + expect(helperText).toBeVisible(); + }); + + it('should use the default format when no format is specified', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('2024-10-25'); + }); + + it('should handle the custom format correctly', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('25/10/2024'); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..25fb95ff048 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,91 @@ +import { TextField } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import React from 'react'; + +import type { TextFieldProps } from '@linode/ui'; +import type { DatePickerProps as MuiDatePickerProps } from '@mui/x-date-pickers/DatePicker'; +import type { DateTime } from 'luxon'; + +export interface DatePickerProps + extends Omit, 'onChange' | 'value'> { + /** Error text to display below the input */ + errorText?: string; + /** Format of the date when rendered in the input field. */ + format?: string; + /** Helper text to display below the input */ + helperText?: string; + /** Label to display for the date picker input */ + label?: string; + /** Callback function fired when the value changes */ + onChange: (newDate: DateTime | null) => void; + /** Placeholder text for the date picker input */ + placeholder?: string; + /** Additional props to pass to the underlying TextField component */ + textFieldProps?: Omit; + /** The currently selected date */ + value?: DateTime | null; +} + +export const DatePicker = ({ + format = 'yyyy-MM-dd', + helperText = '', + label = 'Select a date', + onChange, + placeholder = 'Pick a date', + textFieldProps, + value = null, + ...props +}: DatePickerProps) => { + const theme = useTheme(); + + const onChangeHandler = (newDate: DateTime | null) => { + onChange(newDate); + }; + + return ( + + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx new file mode 100644 index 00000000000..7df04ed26cd --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -0,0 +1,145 @@ +import { action } from '@storybook/addon-actions'; +import * as React from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; +import type { DateTime } from 'luxon'; + +type Story = StoryObj; + +export const ControlledExample: Story = { + args: { + label: 'Controlled Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + timeSelectProps: { + label: 'Select Time', + }, + timeZoneSelectProps: { + label: 'Timezone', + onChange: action('Timezone changed'), + }, + }, + render: (args) => { + const ControlledDateTimePicker = () => { + const [ + selectedDateTime, + setSelectedDateTime, + ] = React.useState(args.value || null); + + const handleChange = (newDateTime: DateTime | null) => { + setSelectedDateTime(newDateTime); + action('Controlled dateTime change')(newDateTime?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +export const DefaultExample: Story = { + args: { + label: 'Default Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + onChange: action('Date-Time selected'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + }, +}; + +export const WithErrorText: Story = { + args: { + errorText: 'This field is required', + label: 'Date-Time Picker with Error', + onApply: action('Apply clicked with error'), + onCancel: action('Cancel clicked with error'), + onChange: action('Date-Time selected with error'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + }, +}; + +const meta: Meta = { + argTypes: { + dateCalendarProps: { + control: { type: 'object' }, + description: 'Additional props for the DateCalendar component.', + }, + errorText: { + control: { type: 'text' }, + description: 'Error text for the date picker field.', + }, + format: { + control: { type: 'text' }, + description: 'Format for displaying the date-time.', + }, + label: { + control: { type: 'text' }, + description: 'Label for the input field.', + }, + onApply: { + action: 'applyClicked', + description: 'Callback when the "Apply" button is clicked.', + }, + onCancel: { + action: 'cancelClicked', + description: 'Callback when the "Cancel" button is clicked.', + }, + onChange: { + action: 'dateTimeChanged', + description: 'Callback when the date-time changes.', + }, + placeholder: { + control: { type: 'text' }, + description: 'Placeholder text for the input field.', + }, + showTime: { + control: { type: 'boolean' }, + description: 'Whether to show the time selector.', + }, + showTimeZone: { + control: { type: 'boolean' }, + description: 'Whether to show the timezone selector.', + }, + sx: { + control: { type: 'object' }, + description: 'Styles to apply to the root element.', + }, + timeSelectProps: { + control: { type: 'object' }, + description: 'Props for customizing the TimePicker component.', + }, + timeZoneSelectProps: { + control: { type: 'object' }, + description: 'Props for customizing the TimeZoneSelect component.', + }, + value: { + control: { type: 'date' }, + description: 'Initial or controlled dateTime value.', + }, + }, + args: { + format: 'yyyy-MM-dd HH:mm', + label: 'Date-Time Picker', + placeholder: 'Select a date and time', + }, + component: DateTimePicker, + title: 'Components/DatePicker/DateTimePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000000..12f1795a747 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx @@ -0,0 +1,143 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { DateTimePickerProps } from './DateTimePicker'; + +const defaultProps: DateTimePickerProps = { + label: 'Select Date and Time', + onApply: vi.fn(), + onCancel: vi.fn(), + onChange: vi.fn(), + placeholder: 'yyyy-MM-dd HH:mm', + value: DateTime.fromISO('2024-10-25T15:30:00'), +}; + +describe('DateTimePicker Component', () => { + it('should render the DateTimePicker component with the correct label and placeholder', () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + expect(textField).toBeVisible(); + expect(textField).toHaveAttribute('placeholder', 'yyyy-MM-dd HH:mm'); + }); + + it('should open the Popover when the TextField is clicked', async () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + await userEvent.click(textField); + expect(screen.getByRole('dialog')).toBeVisible(); // Verifying the Popover is open + }); + + it('should call onCancel when the Cancel button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await userEvent.click(cancelButton); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('should call onApply when the Apply button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const applyButton = screen.getByRole('button', { name: /Apply/i }); + await userEvent.click(applyButton); + expect(defaultProps.onApply).toHaveBeenCalled(); + expect(defaultProps.onChange).toHaveBeenCalledWith(expect.any(DateTime)); // Ensuring onChange was called with a DateTime object + }); + + it('should handle date changes correctly', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a date (e.g., 15th of the month) + const dateButton = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateButton); + + // Check that the displayed value has been updated correctly (this assumes the date format) + expect(defaultProps.onChange).toHaveBeenCalled(); + }); + + it('should handle timezone changes correctly', async () => { + const timezoneChangeMock = vi.fn(); // Create a mock function + + const updatedProps = { + ...defaultProps, + timeZoneSelectProps: { onChange: timezoneChangeMock, value: 'UTC' }, + }; + + renderWithTheme(); + + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a timezone from the TimeZoneSelect + const timezoneInput = screen.getByPlaceholderText(/Choose a Timezone/i); + await userEvent.click(timezoneInput); + + // Select a timezone from the dropdown options + await userEvent.click( + screen.getByRole('option', { name: '(GMT -11:00) Niue Time' }) + ); + + // Click the Apply button to trigger the change + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Verify that the onChange function was called with the expected value + expect(timezoneChangeMock).toHaveBeenCalledWith('Pacific/Niue'); + }); + + it('should display the error text when provided', () => { + renderWithTheme( + + ); + expect(screen.getByText(/Invalid date-time/i)).toBeVisible(); + }); + + it('should format the date-time correctly when a custom format is provided', () => { + renderWithTheme( + + ); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + + expect(textField).toHaveValue('25/10/2024 15:30'); + }); + it('should not render the time selector when showTime is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timePicker = screen.queryByLabelText(/Select Time/i); // Label from timeSelectProps + expect(timePicker).not.toBeInTheDocument(); + }); + + it('should not render the timezone selector when showTimeZone is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timeZoneSelect = screen.queryByLabelText(/Timezone/i); // Label from timeZoneSelectProps + expect(timeZoneSelect).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx new file mode 100644 index 00000000000..b503ba37674 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -0,0 +1,268 @@ +import { Divider } from '@linode/ui'; +import { Box } from '@linode/ui'; +import { TextField } from '@linode/ui'; +import { Grid, Popover } from '@mui/material'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { TimePicker } from '@mui/x-date-pickers/TimePicker'; +import React, { useEffect, useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; + +import { TimeZoneSelect } from './TimeZoneSelect'; + +import type { TextFieldProps } from '@linode/ui'; +import type { SxProps, Theme } from '@mui/material/styles'; +import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; +import type { DateTime } from 'luxon'; + +export interface DateTimePickerProps { + /** Additional props for the DateCalendar */ + dateCalendarProps?: Partial>; + /** Error text for the date picker field */ + errorText?: string; + /** Format for displaying the date-time */ + format?: string; + /** Label for the input field */ + label?: string; + /** Callback when the "Apply" button is clicked */ + onApply?: () => void; + /** Callback when the "Cancel" button is clicked */ + onCancel?: () => void; + /** Callback when date-time changes */ + onChange: (dateTime: DateTime | null) => void; + /** Placeholder text for the input field */ + placeholder?: string; + /** Whether to show the time selector */ + showTime?: boolean; + /** Whether to show the timezone selector */ + showTimeZone?: boolean; + /** + * Any additional styles to apply to the root element. + */ + sx?: SxProps; + /** Props for customizing the TimePicker component */ + timeSelectProps?: { + label?: string; + onChange?: (time: null | string) => void; + value?: null | string; + }; + /** Props for customizing the TimeZoneSelect component */ + timeZoneSelectProps?: { + label?: string; + onChange?: (timezone: string) => void; + value?: null | string; + }; + /** Initial or controlled dateTime value */ + value?: DateTime | null; +} + +export const DateTimePicker = ({ + dateCalendarProps = {}, + errorText = '', + format = 'yyyy-MM-dd HH:mm', + label = 'Select Date and Time', + onApply, + onCancel, + onChange, + placeholder = 'yyyy-MM-dd HH:mm', + showTime = true, + showTimeZone = true, + sx, + timeSelectProps = {}, + timeZoneSelectProps = {}, + value = null, +}: DateTimePickerProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const [selectedDateTime, setSelectedDateTime] = useState( + value + ); + const [selectedTimeZone, setSelectedTimeZone] = useState( + timeZoneSelectProps.value || null + ); + + const TimePickerFieldProps: TextFieldProps = { + label: timeSelectProps?.label ?? 'Select Time', + noMarginTop: true, + }; + + const handleDateChange = (newDate: DateTime | null) => { + setSelectedDateTime((prev) => + newDate + ? newDate.set({ + hour: prev?.hour || 0, + minute: prev?.minute || 0, + }) + : null + ); + }; + + const handleTimeChange = (newTime: DateTime | null) => { + if (newTime) { + setSelectedDateTime((prev) => + prev ? prev.set({ hour: newTime.hour, minute: newTime.minute }) : prev + ); + } + }; + + const handleTimeZoneChange = (newTimeZone: string) => { + setSelectedTimeZone(newTimeZone); + if (timeZoneSelectProps.onChange) { + timeZoneSelectProps.onChange(newTimeZone); + } + }; + + const handleApply = () => { + setAnchorEl(null); + onChange(selectedDateTime); + + if (onApply) { + onApply(); + } + }; + + const handleClose = () => { + setAnchorEl(null); + if (onCancel) { + onCancel(); + } + }; + + useEffect(() => { + if (timeZoneSelectProps.value) { + setSelectedTimeZone(timeZoneSelectProps.value); + } + }, [timeZoneSelectProps.value]); + + return ( + + + setAnchorEl(event.currentTarget)} + placeholder={placeholder} + /> + + + + ({ + '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { + justifyContent: 'space-between', + }, + '& .MuiDayCalendar-weekDayLabel': { + fontSize: '0.875rem', + }, + '& .MuiPickersCalendarHeader-label': { + fontFamily: theme.font.bold, + }, + '& .MuiPickersCalendarHeader-root': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + fontSize: '0.875rem', + paddingBottom: theme.spacing(1), + }, + '& .MuiPickersDay-root': { + fontSize: '0.875rem', + margin: `${theme.spacing(0.5)}px`, + }, + borderRadius: `${theme.spacing(2)}`, + borderWidth: '0px', + })} + /> + + {showTime && ( + + ({ + justifyContent: 'center', + marginBottom: theme.spacing(1 / 2), + marginTop: theme.spacing(1 / 2), + padding: 0, + }), + }, + layout: { + sx: (theme: Theme) => ({ + '& .MuiPickersLayout-contentWrapper': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + }, + border: `1px solid ${theme.borderColors.divider}`, + }), + }, + openPickerButton: { + sx: { padding: 0 }, + }, + popper: { + sx: (theme: Theme) => ({ + ul: { + borderColor: `${theme.borderColors.divider} !important`, + }, + }), + }, + textField: TimePickerFieldProps, + }} + onChange={handleTimeChange} + slots={{ textField: TextField }} + value={selectedDateTime || null} + /> + + )} + {showTimeZone && ( + + + + )} + + + + + ({ + marginBottom: theme.spacing(1), + marginRight: theme.spacing(2), + })} + /> + + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx new file mode 100644 index 00000000000..f34ac6b190b --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -0,0 +1,117 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + args: { + endDateErrorMessage: '', + endDateTimeValue: null, + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + showEndTimeZone: true, + showStartTimeZone: true, + startDateErrorMessage: '', + startDateTimeValue: null, + startLabel: 'Start Date and Time', + startTimeZoneValue: null, + }, + render: (args) => , +}; + +export const WithInitialValues: Story = { + args: { + endDateTimeValue: DateTime.now(), + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + showEndTimeZone: true, + showStartTimeZone: true, + startDateTimeValue: DateTime.now().minus({ days: 1 }), + startLabel: 'Start Date and Time', + startTimeZoneValue: 'America/New_York', + }, +}; + +export const WithCustomErrors: Story = { + args: { + endDateErrorMessage: 'End date must be after the start date.', + endDateTimeValue: DateTime.now().minus({ days: 1 }), + endLabel: 'Custom End Label', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + startDateErrorMessage: 'Start date must be before the end date.', + startDateTimeValue: DateTime.now().minus({ days: 2 }), + startLabel: 'Custom Start Label', + }, +}; + +const meta: Meta = { + argTypes: { + endDateErrorMessage: { + control: 'text', + description: 'Custom error message for invalid end date', + }, + endDateTimeValue: { + control: 'date', + description: 'Initial or controlled value for the end date-time', + }, + endLabel: { + control: 'text', + description: 'Custom label for the end date-time picker', + }, + format: { + control: 'text', + description: 'Format for displaying the date-time', + }, + onChange: { + action: 'DateTime range changed', + description: 'Callback when the date-time range changes', + }, + showEndTimeZone: { + control: 'boolean', + description: + 'Whether to show the timezone selector for the end date picker', + }, + showStartTimeZone: { + control: 'boolean', + description: + 'Whether to show the timezone selector for the start date picker', + }, + startDateErrorMessage: { + control: 'text', + description: 'Custom error message for invalid start date', + }, + startDateTimeValue: { + control: 'date', + description: 'Initial or controlled value for the start date-time', + }, + startLabel: { + control: 'text', + description: 'Custom label for the start date-time picker', + }, + startTimeZoneValue: { + control: 'text', + description: 'Initial or controlled value for the start timezone', + }, + sx: { + control: 'object', + description: 'Styles to apply to the root element', + }, + }, + args: { + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + startLabel: 'Start Date and Time', + }, + component: DateTimeRangePicker, + title: 'Components/DatePicker/DateTimeRangePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx new file mode 100644 index 00000000000..df0314b6607 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -0,0 +1,105 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +describe('DateTimeRangePicker Component', () => { + const onChangeMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render start and end DateTimePickers with correct labels', () => { + renderWithTheme(); + + expect(screen.getByLabelText('Start Date and Time')).toBeVisible(); + expect(screen.getByLabelText('End Date and Time')).toBeVisible(); + }); + + it('should call onChange when start date is changed', async () => { + renderWithTheme(); + + // Open start date picker + await userEvent.click(screen.getByLabelText('Start Date and Time')); + + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Check if the onChange function is called with the expected DateTime value + expect(onChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ day: 10 }), + null, + null + ); + }); + + it('should show error when end date-time is before start date-time', async () => { + renderWithTheme(); + + // Set start date-time to the 15th + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Open the end date picker + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + + // Check if the date before the start date is disabled via a class or attribute + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm error message is not shown since the click was blocked + expect( + screen.getByText('End date/time cannot be before the start date/time.') + ).toBeInTheDocument(); + }); + + it('should show error when start date-time is after end date-time', async () => { + renderWithTheme( + + ); + + // Set the end date-time to the 15th + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set the start date-time to the 10th (which is earlier than the end date-time) + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '20' })); // Invalid date + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm the error message is displayed + expect( + screen.getByText('Start date/time cannot be after the end date/time.') + ).toBeInTheDocument(); + }); + + it('should display custom error messages when start date-time is after end date-time', async () => { + renderWithTheme( + + ); + + // Confirm the custom error message is displayed for the start date + expect(screen.getByText('Custom start date error')).toBeInTheDocument(); + expect(screen.getByText('Custom end date error')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx new file mode 100644 index 00000000000..66170f05586 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -0,0 +1,144 @@ +import { Box } from '@linode/ui'; +import React, { useState } from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { SxProps, Theme } from '@mui/material/styles'; +import type { DateTime } from 'luxon'; + +interface DateTimeRangePickerProps { + /** Custom error message for invalid end date */ + endDateErrorMessage?: string; + /** Initial or controlled value for the end date-time */ + endDateTimeValue?: DateTime | null; + /** Custom labels for the start and end date/time fields */ + endLabel?: string; + /** Format for displaying the date-time */ + format?: string; + /** Callback when the date-time range changes */ + onChange: ( + start: DateTime | null, + end: DateTime | null, + startTimeZone?: null | string + ) => void; + /** Whether to show the end timezone field for the end date picker */ + showEndTimeZone?: boolean; + /** Whether to show the start timezone field for the end date picker */ + showStartTimeZone?: boolean; + /** Custom error message for invalid start date */ + startDateErrorMessage?: string; + /** Initial or controlled value for the start date-time */ + startDateTimeValue?: DateTime | null; + /** Custom labels for the start and end date/time fields */ + startLabel?: string; + /** Initial or controlled value for the start timezone */ + startTimeZoneValue?: null | string; + /** + * Any additional styles to apply to the root element. + */ + sx?: SxProps; +} + +export const DateTimeRangePicker = ({ + endDateErrorMessage, + endDateTimeValue = null, + endLabel = 'End Date and Time', + format = 'yyyy-MM-dd HH:mm', + onChange, + showEndTimeZone = false, + showStartTimeZone = false, + startDateErrorMessage, + startDateTimeValue = null, + startLabel = 'Start Date and Time', + startTimeZoneValue = null, + sx, +}: DateTimeRangePickerProps) => { + const [startDateTime, setStartDateTime] = useState( + startDateTimeValue + ); + const [endDateTime, setEndDateTime] = useState( + endDateTimeValue + ); + const [startTimeZone, setStartTimeZone] = useState( + startTimeZoneValue + ); + + const [startDateError, setStartDateError] = useState(); + const [endDateError, setEndDateError] = useState(); + + const validateDates = ( + start: DateTime | null, + end: DateTime | null, + source: 'end' | 'start' + ) => { + if (start && end) { + if (source === 'start' && start > end) { + setStartDateError('Start date/time cannot be after the end date/time.'); + setEndDateError(undefined); + return; + } + if (source === 'end' && end < start) { + setEndDateError('End date/time cannot be before the start date/time.'); + setStartDateError(undefined); + return; + } + } + // Reset validation errors if valid + setStartDateError(undefined); + setEndDateError(undefined); + }; + + const handleStartDateTimeChange = (newStart: DateTime | null) => { + setStartDateTime(newStart); + validateDates(newStart, endDateTime, 'start'); + + onChange(newStart, endDateTime, startTimeZone); + }; + + const handleEndDateTimeChange = (newEnd: DateTime | null) => { + setEndDateTime(newEnd); + validateDates(startDateTime, newEnd, 'end'); + + onChange(startDateTime, newEnd, startTimeZone); + }; + + const handleStartTimeZoneChange = (newTimeZone: null | string) => { + setStartTimeZone(newTimeZone); + + onChange(startDateTime, endDateTime, newTimeZone); + }; + + return ( + + {/* Start DateTime Picker */} + + + {/* End DateTime Picker */} + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx new file mode 100644 index 00000000000..f4bd68c97a3 --- /dev/null +++ b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx @@ -0,0 +1,64 @@ +import { Autocomplete } from '@linode/ui'; +import { DateTime } from 'luxon'; +import React from 'react'; + +import { timezones } from 'src/assets/timezones/timezones'; + +type Timezone = typeof timezones[number]; + +interface TimeZoneSelectProps { + disabled?: boolean; + errorText?: string; + label?: string; + noMarginTop?: boolean; + onChange: (timezone: string) => void; + value: null | string; +} + +const getOptionLabel = ({ label, offset }: Timezone) => { + const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { + minimumIntegerDigits: 2, + useGrouping: false, + }); + const hours = Math.floor(Math.abs(offset) / 60); + const isPositive = Math.abs(offset) === offset ? '+' : '-'; + + return `(GMT ${isPositive}${hours}:${minutes}) ${label}`; +}; + +const getTimezoneOptions = () => { + return timezones + .map((tz) => { + const offset = DateTime.now().setZone(tz.name).offset; + const label = getOptionLabel({ ...tz, offset }); + return { label, offset, value: tz.name }; + }) + .sort((a, b) => a.offset - b.offset); +}; + +const timezoneOptions = getTimezoneOptions(); + +export const TimeZoneSelect = ({ + disabled = false, + errorText, + label = 'Timezone', + noMarginTop = false, + onChange, + value, +}: TimeZoneSelectProps) => { + return ( + option.value === value) ?? undefined + } + autoHighlight + disabled={disabled} + errorText={errorText} + label={label} + noMarginTop={noMarginTop} + onChange={(e, option) => onChange(option?.value || '')} + options={timezoneOptions} + placeholder="Choose a Timezone" + /> + ); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 5aa31ba2e09..ed0a8c5a00d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -57,4 +57,4 @@ "prettier": "~2.2.1", "vite-plugin-svgr": "^3.2.0" } -} +} \ No newline at end of file