diff --git a/packages/time-input/src/TimeInput.stories.tsx b/packages/time-input/src/TimeInput.stories.tsx index e90ce7a0a5..ab4a789796 100644 --- a/packages/time-input/src/TimeInput.stories.tsx +++ b/packages/time-input/src/TimeInput.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import React, { useState } from 'react'; import { storybookArgTypes, @@ -52,11 +53,25 @@ export default meta; const Template: StoryFn = props => { const [value, setValue] = useState( - new Date('1990-02-20T14:30:50Z'), + new Date('2026-02-20T04:00:00Z'), ); return ( - setValue(time)} /> +
+ { + setValue(time); + console.log('Storybook: onTimeChange ⏰', { + localTime: time, + utcTime: time?.toUTCString(), + }); + }} + /> +

Time zone: {props.timeZone}

+

UTC value: {value?.toUTCString()}

+
); }; diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx index e916be3eb7..3dea3943e5 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx @@ -1,9 +1,870 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import React from 'react'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -import { TimeInputInputs } from '.'; +import { Month, newUTC, SupportedLocales } from '@leafygreen-ui/date-utils'; +import { getTestUtils as getSelectTestUtils } from '@leafygreen-ui/select/testing'; + +import { TimeInputProvider } from '../Context/TimeInputContext/TimeInputContext'; +import { TimeInputProviderProps } from '../Context/TimeInputContext/TimeInputContext.types'; +import { TimeInputDisplayProvider } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; +import { TimeInputDisplayProviderProps } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext.types'; +import { getLgIds } from '../utils/getLgIds'; + +import { TimeInputInputs, TimeInputInputsProps } from '.'; + +const lgIds = getLgIds(); + +const renderTimeInputInputs = ({ + displayProps, + providerProps, + inputsProps, +}: { + displayProps?: Partial; + providerProps?: Partial; + inputsProps?: Partial; +}) => { + const defaultProviderProps: TimeInputProviderProps = { + value: undefined, + setValue: jest.fn(), + }; + + const mergedProviderProps = { ...defaultProviderProps, ...providerProps }; + + const result = render( + + + + + , + ); + + const rerenderTimeInputInputs = ({ + newDisplayProps, + newProviderProps, + newInputsProps, + }: { + newDisplayProps?: Partial; + newProviderProps?: Partial; + newInputsProps?: Partial; + }) => { + result.rerender( + + + + + , + ); + }; + + // TODO: replace with test harnesses + const hourInput = result.container.querySelector( + 'input[aria-label="hour"]', + ) as HTMLInputElement; + const minuteInput = result.container.querySelector( + 'input[aria-label="minute"]', + ) as HTMLInputElement; + const secondInput = result.container.querySelector( + 'input[aria-label="second"]', + ) as HTMLInputElement; + + if (!(hourInput && minuteInput && secondInput)) { + throw new Error('Some or all input segments are missing'); + } + + return { + ...result, + rerenderTimeInputInputs, + hourInput, + minuteInput, + secondInput, + }; +}; describe('packages/time-input-inputs', () => { - test('condition', () => {}); + beforeEach(() => { + // Mock the current date/time in UTC + jest.useFakeTimers().setSystemTime( + new Date(Date.UTC(2025, Month.January, 1, 0, 0, 0)), // January 1, 2025 00:00:00 UTC + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('Rendering', () => { + test('Renders empty segments when the value is null', () => { + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: null }, + }); + expect(hourInput.value).toBe(''); + expect(minuteInput.value).toBe(''); + expect(secondInput.value).toBe(''); + }); + + test('Renders filled segments when a value is passed', () => { + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T00:00:00Z') }, + displayProps: { + timeZone: 'UTC', + }, + }); + expect(hourInput.value).toBe('00'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + }); + + test('Renders empty segments when an invalid value is passed', () => { + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: new Date('invalid') }, + }); + expect(hourInput.value).toBe(''); + expect(minuteInput.value).toBe(''); + expect(secondInput.value).toBe(''); + }); + + describe('Different time zones', () => { + describe('UTC', () => { + describe('en_US (12 hour format)', () => { + test('Renders the segments and select unit', () => { + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { + value: new Date(newUTC(2025, Month.February, 20, 0, 0, 0)), // February 20, 2025 00:00:00 UTC + }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + // February 20, 2025 00:00:00 UTC in en_US is February 20, 2025 12:00:00 AM (UTC-5 hours) + expect(hourInput.value).toBe('12'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + }); + }); + describe('ISO_8601 (24 hour format)', () => { + test('Renders the segments', () => { + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { + value: new Date(newUTC(2025, Month.February, 20, 0, 0, 0)), // February 20, 2025 00:00:00 UTC + }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.ISO_8601, + }, + }); + + // February 20, 2025 00:00:00 UTC in ISO_8601 is February 20, 2025 00:00:00 (UTC+0 hours) + expect(hourInput.value).toBe('00'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + }); + }); + }); + describe('America/New_York', () => { + describe('en_US (12 hour format)', () => { + test('Renders the segments and select unit', () => { + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { + value: new Date(newUTC(2025, Month.February, 20, 0, 0, 0)), // February 20, 2025 00:00:00 UTC + }, + displayProps: { + timeZone: 'America/New_York', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + // February 20, 2025 00:00:00 UTC in America/New_York is February 19, 2025 7:00:00 PM (UTC-5 hours) + expect(hourInput.value).toBe('07'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + }); + describe('ISO_8601 (24 hour format)', () => { + test('Renders the segments', () => { + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { + value: new Date(newUTC(2025, Month.February, 20, 0, 0, 0)), // February 20, 2025 00:00:00 UTC + }, + displayProps: { + timeZone: 'America/New_York', + locale: SupportedLocales.ISO_8601, + }, + }); + + // February 20, 2025 00:00:00 UTC in America/New_York is February 19, 2025 19:00:00 (UTC-5 hours) + expect(hourInput.value).toBe('19'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + }); + }); + }); + describe('Pacific/Kiritimati', () => { + describe('en_US (12 hour format)', () => { + test('Renders the segments and select unit', () => { + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { + value: new Date(newUTC(2025, Month.February, 20, 0, 0, 0)), // February 20, 2025 00:00:00 UTC + }, + displayProps: { + timeZone: 'Pacific/Kiritimati', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + // February 20, 2025 00:00:00 UTC in Pacific/Kiritimati is February 20, 2025 2:00:00 PM (UTC+14 hours) + expect(hourInput.value).toBe('02'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + }); + describe('ISO_8601 (24 hour format)', () => { + test('Renders the segments', () => { + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { + value: new Date(newUTC(2025, Month.February, 20, 0, 0, 0)), // February 20, 2025 00:00:00 UTC + }, + displayProps: { + timeZone: 'Pacific/Kiritimati', + locale: SupportedLocales.ISO_8601, + }, + }); + + // February 20, 2025 00:00:00 UTC in Pacific/Kiritimati is February 20, 2025 14:00:00 (UTC+14 hours) + expect(hourInput.value).toBe('14'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + }); + }); + }); + }); + + test('does not render the select when the locale is 24h', () => { + const { queryByTestId } = renderTimeInputInputs({ + displayProps: { + locale: SupportedLocales.ISO_8601, + }, + }); + expect(queryByTestId(lgIds.select)).not.toBeInTheDocument(); + }); + + test.todo('renders 24 Hour label when the locale is 24h'); + }); + + describe('Re-rendering', () => { + test('With new value updates the segments and select unit', () => { + const { hourInput, minuteInput, secondInput, rerenderTimeInputInputs } = + renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T00:00:00Z') }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + const selectTestUtils = getSelectTestUtils(lgIds.select); + + expect(hourInput.value).toBe('12'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + rerenderTimeInputInputs({ + newProviderProps: { value: new Date('2025-01-02T20:20:20Z') }, + }); + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('20'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + + test('With null clears the segments and keeps the select unit value', () => { + const { hourInput, minuteInput, secondInput, rerenderTimeInputInputs } = + renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T00:00:00Z') }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + const selectTestUtils = getSelectTestUtils(lgIds.select); + expect(hourInput.value).toBe('12'); + expect(minuteInput.value).toBe('00'); + expect(secondInput.value).toBe('00'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + rerenderTimeInputInputs({ + newProviderProps: { value: null }, + }); + + expect(hourInput.value).toBe(''); + expect(minuteInput.value).toBe(''); + expect(secondInput.value).toBe(''); + expect(selectTestUtils.getInputValue()).toBe('AM'); + }); + + test('With invalid value does not update the segments or select unit', () => { + const { hourInput, minuteInput, secondInput, rerenderTimeInputInputs } = + renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T20:20:30Z') }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + const selectTestUtils = getSelectTestUtils(lgIds.select); + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + + rerenderTimeInputInputs({ + newProviderProps: { value: new Date('invalid') }, + }); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + + describe('TimeZone', () => { + describe('UTC to America/New_York', () => { + describe('en_US (12 hour format)', () => { + test('updates the segments and select unit but does not call the value setter', () => { + const setValue = jest.fn(); + const { + hourInput, + minuteInput, + secondInput, + rerenderTimeInputInputs, + } = renderTimeInputInputs({ + providerProps: { + value: new Date('2025-01-01T02:20:30Z'), + }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + const selectTestUtils = getSelectTestUtils(lgIds.select); + expect(hourInput.value).toBe('02'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + rerenderTimeInputInputs({ + newDisplayProps: { + timeZone: 'America/New_York', + }, + newProviderProps: { setValue }, + }); + expect(hourInput.value).toBe('09'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + + expect(setValue).not.toHaveBeenCalled(); + }); + }); + describe('ISO_8601 (24 hour format)', () => { + test('updates the segments but does not call the value setter', () => { + const setValue = jest.fn(); + const { + hourInput, + minuteInput, + secondInput, + rerenderTimeInputInputs, + } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T02:20:30Z') }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.ISO_8601, + }, + }); + expect(hourInput.value).toBe('02'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + rerenderTimeInputInputs({ + newDisplayProps: { + timeZone: 'America/New_York', + }, + newProviderProps: { setValue }, + }); + // 2025-01-01T02:20:30Z in America/New_York is December 31, 2024 21:20:30 (UTC-5 hours) + expect(hourInput.value).toBe('21'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + expect(setValue).not.toHaveBeenCalled(); + }); + }); + }); + describe('UTC to Pacific/Kiritimati', () => { + describe('en_US (12 hour format)', () => { + test('updates the segments and select unit but does not call the value setter', () => { + const setValue = jest.fn(); + const { + hourInput, + minuteInput, + secondInput, + rerenderTimeInputInputs, + } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T02:20:30Z') }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + const selectTestUtils = getSelectTestUtils(lgIds.select); + expect(hourInput.value).toBe('02'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + rerenderTimeInputInputs({ + newDisplayProps: { + timeZone: 'Pacific/Kiritimati', + }, + newProviderProps: { setValue }, + }); + // 2025-01-01T02:20:30Z in Pacific/Kiritimati is January 1, 2025 16:20:30 (UTC+14 hours) = 4:20:30 PM + expect(hourInput.value).toBe('04'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + + expect(setValue).not.toHaveBeenCalled(); + }); + }); + describe('ISO_8601 (24 hour format)', () => { + test('updates the segments but does not call the value setter', () => { + const setValue = jest.fn(); + const { + hourInput, + minuteInput, + secondInput, + rerenderTimeInputInputs, + } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T02:20:30Z') }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.ISO_8601, + }, + }); + expect(hourInput.value).toBe('02'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + rerenderTimeInputInputs({ + newDisplayProps: { + timeZone: 'Pacific/Kiritimati', + }, + newProviderProps: { setValue }, + }); + // 2025-01-01T02:20:30Z in Pacific/Kiritimati is January 1, 2025 16:20:30 (UTC+14 hours) + expect(hourInput.value).toBe('16'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + expect(setValue).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe('Typing', () => { + describe('Segment change handler', () => { + describe('Single segment', () => { + test.todo('is called when typing into a segment'); + test.todo('is called when deleting a segment'); + }); + }); + + describe('Value setter', () => { + describe('Single segment', () => { + // component is not dirty yet + test('is not called when typing into a segment', () => { + const setValue = jest.fn(); + const { hourInput } = renderTimeInputInputs({ + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + userEvent.type(hourInput, '08'); + expect(setValue).not.toHaveBeenCalled(); + }); + test('is called when pressing backspace in a single segment (null date)', () => { + const setValue = jest.fn(); + const { hourInput } = renderTimeInputInputs({ + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'UTC', + }, + }); + userEvent.type(hourInput, '08'); + userEvent.type(hourInput, '{backspace}'); + expect(setValue).toHaveBeenCalledWith(null); + }); + }); + + describe('With no initial value', () => { + describe('With different time zones', () => { + describe('UTC', () => { + test('is called when typing an explicit value', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'UTC', + }, + }); + userEvent.type(hourInput, '08'); + userEvent.type(minuteInput, '20'); + userEvent.type(secondInput, '30'); + + // Without a value, new Date() will be used to get the current date. + // 2025-01-01T00:00:00Z UTC is January 1, 2025 00:00:00 in UTC (UTC+0 hours) + expect(setValue).toHaveBeenCalledWith( + new Date('2025-01-01T08:20:30Z'), + ); + }); + }); + + describe('America/New_York', () => { + test('is called when typing an explicit value', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'America/New_York', + }, + }); + userEvent.type(hourInput, '08'); + userEvent.type(minuteInput, '20'); + userEvent.type(secondInput, '30'); + + // Without a value, new Date() will be used to get the current date in the time zone. + // current date is january 1, 2025 00:00:00 in UTC is December 31, 2024 in America/New_York + // December 31, 2024 08:20:30 America/New_York is December 31, 2024 13:20:30 in UTC (UTC-5 hours) + expect(setValue).toHaveBeenCalledWith( + new Date('2024-12-31T13:20:30Z'), + ); + }); + }); + + describe('Pacific/Kiritimati', () => { + test('is called when typing an explicit value', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = + renderTimeInputInputs({ + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'Pacific/Kiritimati', + }, + }); + userEvent.type(hourInput, '08'); + userEvent.type(minuteInput, '20'); + userEvent.type(secondInput, '30'); + + // Without a value, new Date() will be used to get the current date in the time zone. + // current date is january 1, 2025 00:00:00 in UTC is January 1, 2025 in Pacific/Kiritimati + // January 1, 2025 08:20:30 Pacific/Kiritimati is December 31, 2024 18:20:30 in UTC (UTC+14 hours) + expect(setValue).toHaveBeenCalledWith( + new Date('2024-12-31T18:20:30Z'), + ); + }); + }); + }); + + test('is not called when typing an ambiguous value', () => { + const setValue = jest.fn(); + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs( + { + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'UTC', + }, + }, + ); + userEvent.type(hourInput, '08'); + userEvent.type(minuteInput, '20'); + userEvent.type(secondInput, '3'); + + expect(setValue).not.toHaveBeenCalled(); + }); + }); + + describe('With initial value', () => { + test('is called when an explicit value is entered in a segment', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs( + { + providerProps: { + value: new Date('2025-01-01T08:20:30Z'), + setValue, + }, + displayProps: { + timeZone: 'UTC', + }, + }, + ); + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + userEvent.type(hourInput, '09'); + + expect(setValue).toHaveBeenCalledWith( + new Date('2025-01-01T09:20:30Z'), + ); + }); + test('is not called when an ambiguous value is entered in a segment', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs( + { + providerProps: { + value: new Date('2025-01-01T08:20:30Z'), + setValue, + }, + displayProps: { + timeZone: 'UTC', + }, + }, + ); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + userEvent.type(hourInput, '0'); + + expect(setValue).not.toHaveBeenCalled(); + }); + + test('is called when all inputs are cleared', () => { + const setValue = jest.fn(); + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs( + { + providerProps: { + value: new Date('2025-01-01T08:20:30Z'), + setValue, + }, + displayProps: { + timeZone: 'UTC', + }, + }, + ); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + userEvent.type(secondInput, '{backspace}{backspace}'); + userEvent.type(minuteInput, '{backspace}{backspace}'); + userEvent.type(hourInput, '{backspace}{backspace}'); + expect(setValue).toHaveBeenCalledWith(null); + }); + + test('is called when the new date is invalid', () => { + const setValue = jest.fn(); + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs( + { + providerProps: { + value: new Date('2025-01-01T08:20:30Z'), + setValue, + }, + displayProps: { + timeZone: 'UTC', + }, + }, + ); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + + userEvent.type(secondInput, '{backspace}{backspace}'); + expect(setValue).toHaveBeenCalled(); + + // Returns invalid date object + const calledWith = setValue.mock.calls[0][0]; + expect(calledWith).toBeInstanceOf(Date); + expect(calledWith.getTime()).toBeNaN(); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe(''); + }); + }); + }); + }); + + describe('Select', () => { + test('calls the value setter when the select unit is changed if the segments are filled', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T08:20:30Z'), setValue }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + userEvent.click(selectTestUtils.getInput()); + userEvent.click(selectTestUtils.getOptionByValue('PM')!); + expect(setValue).toHaveBeenCalledWith(new Date('2025-01-01T20:20:30Z')); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + + test('does not call the value setter when the select unit is the same', () => { + const setValue = jest.fn(); + + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T08:20:30Z'), setValue }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + userEvent.click(selectTestUtils.getInput()); + userEvent.click(selectTestUtils.getOptionByValue('AM')!); + expect(setValue).not.toHaveBeenCalled(); + }); + + describe('no initial value', () => { + test('calls the value setter when the select unit is changed and all the segments are empty', () => { + const setValue = jest.fn(); + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: null, setValue }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + expect(hourInput.value).toBe(''); + expect(minuteInput.value).toBe(''); + expect(secondInput.value).toBe(''); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + userEvent.click(selectTestUtils.getInput()); + userEvent.click(selectTestUtils.getOptionByValue('PM')!); + expect(setValue).toHaveBeenCalledWith(null); + + expect(hourInput.value).toBe(''); + expect(minuteInput.value).toBe(''); + expect(secondInput.value).toBe(''); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + }); + + describe('with initial value', () => { + test('calls the value setter when the select unit is changed and all the segments are empty', () => { + const setValue = jest.fn(); + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T08:20:30Z'), setValue }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + userEvent.click(selectTestUtils.getInput()); + userEvent.click(selectTestUtils.getOptionByValue('PM')!); + expect(setValue).toHaveBeenCalledWith(new Date('2025-01-01T20:20:30Z')); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('PM'); + }); + + test('calls the value setter if some segments are empty', () => { + const setValue = jest.fn(); + const { hourInput, minuteInput, secondInput } = renderTimeInputInputs({ + providerProps: { value: new Date('2025-01-01T08:20:30Z'), setValue }, + displayProps: { + timeZone: 'UTC', + locale: SupportedLocales.en_US, + }, + }); + + const selectTestUtils = getSelectTestUtils(lgIds.select); + + expect(hourInput.value).toBe('08'); + expect(minuteInput.value).toBe('20'); + expect(secondInput.value).toBe('30'); + expect(selectTestUtils.getInputValue()).toBe('AM'); + + userEvent.type(secondInput, '{backspace}{backspace}'); + + userEvent.click(selectTestUtils.getInput()); + userEvent.click(selectTestUtils.getOptionByValue('PM')!); + expect(setValue).toHaveBeenCalled(); + + // Returns invalid date object + const calledWith = setValue.mock.calls[0][0]; + expect(calledWith).toBeInstanceOf(Date); + expect(calledWith.getTime()).toBeNaN(); + }); + }); + }); }); diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index 71f30079a8..c66a38dac5 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -1,72 +1,114 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useEffect } from 'react'; +import { isEqual } from 'lodash'; + +import { isDateObject } from '@leafygreen-ui/date-utils'; -import { unitOptions } from '../constants'; import { useTimeInputContext } from '../Context/TimeInputContext/TimeInputContext'; import { useTimeInputDisplayContext } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; -import { useSelectUnit } from '../hooks'; -import { TimeSegmentsState } from '../shared.types'; +import { OnUpdateCallback, useTimeSegmentsAndSelectUnit } from '../hooks'; import { TimeFormField, TimeFormFieldInputContainer } from '../TimeFormField'; import { TimeInputBox } from '../TimeInputBox/TimeInputBox'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; -import { UnitOption } from '../TimeInputSelect/TimeInputSelect.types'; -import { getFormatPartsValues } from '../utils'; +import { + getFormatPartsValues, + getNewUTCDateFromSegments, + shouldSetValue, +} from '../utils'; import { wrapperBaseStyles } from './TimeInputInputs.styles'; import { TimeInputInputsProps } from './TimeInputInputs.types'; /** * @internal + * This component renders and updates the time segments and select unit. */ export const TimeInputInputs = forwardRef( (_props: TimeInputInputsProps, forwardedRef) => { - const { is12HourFormat, timeZone, locale } = useTimeInputDisplayContext(); - const { value } = useTimeInputContext(); + const { is12HourFormat, timeZone, locale, isDirty, setIsDirty } = + useTimeInputDisplayContext(); + const { value, setValue } = useTimeInputContext(); - const handleSelectChange = (unit: UnitOption) => { - setSelectUnit(unit); - }; + /** if the value is a `Date` the component is dirty, meaning the component has been interacted with */ + useEffect(() => { + if (isDateObject(value) && !isDirty) { + setIsDirty(true); + } + }, [isDirty, setIsDirty, value]); /** - * Gets the time parts from the value + * Handles the update of the segments and select unit. + * + * @param newSegments - The new segments + * @param prevSegments - The previous segments + * @param newSelectUnit - The new select unit + * @param prevSelectUnit - The previous select unit */ - const timeParts = getFormatPartsValues({ - locale: locale, - timeZone: timeZone, - value: value, - }); + const handleSegmentAndSelectUpdate: OnUpdateCallback = ({ + newSegments, + prevSegments, + newSelectUnit, + prevSelectUnit, + }) => { + const hasAnySegmentChanged = !isEqual(newSegments, prevSegments); + const hasSelectUnitChanged = !isEqual(newSelectUnit, prevSelectUnit); - const { hour, minute, second } = timeParts; + // If any segment has changed or the select unit has changed and the time is in 12 hour format, then we need to update the date + // If the time is in 24h format we don't need to check for the select unit since it's not applicable. + if (hasAnySegmentChanged || (hasSelectUnitChanged && is12HourFormat)) { + //Gets the time parts from the value + const { month, day, year } = getFormatPartsValues({ + locale: locale, + timeZone: timeZone, + value: value, + }); - /** - * Creates time segments object - * // TODO: these are temp and will be replaced in the next PR - */ - const segmentObj: TimeSegmentsState = { - hour, - minute, - second, + // Constructs a date object in UTC from day, month, year segments + const newDate = getNewUTCDateFromSegments({ + segments: newSegments, + is12HourFormat, + dateValues: { + day, + month, + year, + }, + timeZone, + dayPeriod: newSelectUnit.displayName, + }); + + // Checks if the new date should be set + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty, + segments: newSegments, + is12HourFormat, + }); + + // TODO: There will be a few more checks added once validation is implemented + if (shouldSetNewValue) setValue(newDate); + } }; /** - * Hook to manage the select unit - * // TODO: This is temp and will be replaced in the next PR + * Hook to manage the time segments and select unit */ - const { selectUnit, setSelectUnit } = useSelectUnit({ - dayPeriod: timeParts.dayPeriod, - value, - unitOptions, - }); + const { segments, setSegment, setSelectUnit, selectUnit } = + useTimeSegmentsAndSelectUnit({ + date: value, + locale, + timeZone, + options: { + onUpdate: handleSegmentAndSelectUpdate, + }, + }); return (
{ - // TODO: This is temp and will be replaced in the next PR - // eslint-disable-next-line no-console - console.log({ segment, value }); + setSegment(segment, value); }} /> @@ -74,7 +116,7 @@ export const TimeInputInputs = forwardRef( { - handleSelectChange(unit); + setSelectUnit(unit); }} /> )} diff --git a/packages/time-input/src/hooks/index.ts b/packages/time-input/src/hooks/index.ts index 91b8fb4a8b..86cd6453c1 100644 --- a/packages/time-input/src/hooks/index.ts +++ b/packages/time-input/src/hooks/index.ts @@ -1 +1,4 @@ -export { useSelectUnit } from './useSelectUnit'; +export { + type OnUpdateCallback, + useTimeSegmentsAndSelectUnit, +} from './useTimeSegmentsAndSelectUnit'; diff --git a/packages/time-input/src/hooks/useSelectUnit/index.ts b/packages/time-input/src/hooks/useSelectUnit/index.ts deleted file mode 100644 index 91b8fb4a8b..0000000000 --- a/packages/time-input/src/hooks/useSelectUnit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSelectUnit } from './useSelectUnit'; diff --git a/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts b/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts deleted file mode 100644 index fa4ab18565..0000000000 --- a/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { DateType, isValidDate } from '@leafygreen-ui/date-utils'; - -import { UnitOption } from '../../TimeInputSelect/TimeInputSelect.types'; - -interface UseSelectUnitReturn { - selectUnit: UnitOption; - setSelectUnit: React.Dispatch>; -} - -/** - * Finds the select unit option based on the day period. - * - * @param dayPeriod - The day period to use for the select unit. - * @param unitOptions - The valid unit options to use for the select unit. - * @returns The select unit option. - */ -const findSelectUnit = ( - dayPeriod: string, - unitOptions: Array, -): UnitOption => { - const selectUnitOption = unitOptions.find( - option => option.displayName === dayPeriod, - ) as UnitOption; - return selectUnitOption; -}; - -/** - * Hook to manage the select unit. - * - * @param dayPeriod - The day period to use for the select unit. - * @param value - The date value passed to the TimeInput component. - * @param unitOptions - The valid unit options to use for the select unit. - * @returns The select unit and the setSelectUnit function. - */ -export const useSelectUnit = ({ - dayPeriod, - value, - unitOptions, -}: { - dayPeriod: string; - value: DateType | undefined; - unitOptions: Array; -}): UseSelectUnitReturn => { - const selectUnitOption = findSelectUnit(dayPeriod, unitOptions); - const [selectUnit, setSelectUnit] = useState(selectUnitOption); - - useEffect(() => { - // Only update the select unit if the value is valid. This way the previous valid value is not lost. - if (isValidDate(value)) { - const selectUnitOption = findSelectUnit(dayPeriod, unitOptions); - setSelectUnit(selectUnitOption); - } - }, [value, dayPeriod, unitOptions, setSelectUnit]); - - return { selectUnit, setSelectUnit }; -}; diff --git a/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/index.ts b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/index.ts new file mode 100644 index 0000000000..35442c5d0f --- /dev/null +++ b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/index.ts @@ -0,0 +1,2 @@ +export { useTimeSegmentsAndSelectUnit } from './useTimeSegmentsAndSelectUnit'; +export type { OnUpdateCallback } from './useTimeSegmentsAndSelectUnit.types'; diff --git a/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentAndSelectUnit.spec.ts b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentAndSelectUnit.spec.ts new file mode 100644 index 0000000000..36580c1d8d --- /dev/null +++ b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentAndSelectUnit.spec.ts @@ -0,0 +1,666 @@ +import { + DateType, + LocaleString, + Month, + newUTC, + SupportedLocales, +} from '@leafygreen-ui/date-utils'; +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { useTimeSegmentsAndSelectUnit } from './useTimeSegmentsAndSelectUnit'; +import { OnUpdateCallback } from './useTimeSegmentsAndSelectUnit.types'; + +const renderUseTimeSegmentsAndSelectUnitHook = ({ + initialDate, + initialTimeZone = 'UTC', + initialLocale = SupportedLocales.ISO_8601, + callback, +}: { + initialDate: DateType; + initialTimeZone?: string; + initialLocale?: LocaleString; + callback?: OnUpdateCallback; +}) => { + const { rerender: _rerender, ...rest } = renderHook( + props => + useTimeSegmentsAndSelectUnit({ + date: props.date, + locale: props.locale, + timeZone: props.timeZone, + options: { onUpdate: props.callback }, + }), + { + initialProps: { + date: initialDate, + locale: initialLocale, + timeZone: initialTimeZone, + callback, + }, + }, + ); + + // Rerender wrapper with defaults + const rerender = (props: { + date: DateType; + locale?: LocaleString; + timeZone?: string; + callback?: OnUpdateCallback; + }) => + _rerender({ + date: props.date, + locale: props?.locale ?? initialLocale, + timeZone: props?.timeZone ?? initialTimeZone, + callback: props?.callback ?? callback, + }); + + return { rerender, ...rest }; +}; + +describe('packages/time-input/hooks/useTimeSegmentsAndSelectUnit', () => { + describe('initial render', () => { + test('returns setter functions', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + }); + + const { segments, setSegment, setSelectUnit, selectUnit } = + result.current; + + expect(segments).toBeDefined(); + expect(setSegment).toBeDefined(); + expect(setSelectUnit).toBeDefined(); + expect(selectUnit).toBeDefined(); + }); + + describe('returns initial state in', () => { + describe('UTC', () => { + describe('returns segments object and setter functions', () => { + test('24h format', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + }); + + const { segments } = result.current; + + expect(segments.hour).toEqual('12'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + }); + test('12h format', () => { + const testDate = newUTC(2023, Month.February, 20, 13, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialLocale: SupportedLocales.en_US, + callback, + }); + + const { segments, selectUnit } = result.current; + + expect(segments.hour).toEqual('01'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + expect(selectUnit).toEqual({ displayName: 'PM', value: 'PM' }); + }); + }); + }); + + describe('America/New_York', () => { + describe('returns segments object and setter functions', () => { + test('24h format', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'America/New_York', + callback, + }); + + const { segments } = result.current; + + // 12:00 UTC = 07:00 EST (UTC-5) + expect(segments.hour).toEqual('07'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + }); + test('12h format', () => { + const testDate = newUTC(2023, Month.February, 20, 20, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'America/New_York', + initialLocale: SupportedLocales.en_US, + callback, + }); + + const { segments, selectUnit } = result.current; + + // 20:00 UTC = 03:00 PM EST (UTC-5) + expect(segments.hour).toEqual('03'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + expect(selectUnit).toEqual({ displayName: 'PM', value: 'PM' }); + }); + }); + }); + + describe('Pacific/Auckland', () => { + describe('returns segments object and setter functions', () => { + test('24h format', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'Pacific/Auckland', + callback, + }); + + const { segments } = result.current; + + // 12:00 UTC = 01:00 NZDT (UTC+13) + expect(segments.hour).toEqual('01'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + }); + test('12h format', () => { + const testDate = newUTC(2023, Month.February, 20, 13, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'Pacific/Auckland', + initialLocale: SupportedLocales.en_US, + callback, + }); + + const { segments, selectUnit } = result.current; + + // 13:00 UTC = 02:00 AM NZDT (UTC+13) + expect(segments.hour).toEqual('02'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + expect(selectUnit).toEqual({ displayName: 'AM', value: 'AM' }); + }); + }); + }); + }); + + test('returns empty segments when date is null', () => { + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: null, + callback, + }); + + const { segments } = result.current; + + expect(segments.hour).toEqual(''); + expect(segments.minute).toEqual(''); + expect(segments.second).toEqual(''); + }); + + test('returns empty segments when date is invalid', () => { + const invalidDate = new Date('invalid'); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: invalidDate, + callback, + }); + + const { segments } = result.current; + + expect(segments.hour).toEqual(''); + expect(segments.minute).toEqual(''); + expect(segments.second).toEqual(''); + }); + }); + + describe('re-rendering', () => { + describe('with a valid value', () => { + describe('UTC', () => { + test('24h format', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + }); + + const { segments } = result.current; + + expect(segments.hour).toEqual('12'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + + rerender({ + date: newUTC(2023, Month.February, 20, 13, 0, 0), + callback, + }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '13', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + test('12h format', () => { + const testDate = newUTC(2023, Month.February, 20, 11, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialLocale: SupportedLocales.en_US, + callback, + }); + + const { segments, selectUnit } = result.current; + + expect(segments.hour).toEqual('11'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + expect(selectUnit).toEqual({ displayName: 'AM', value: 'AM' }); + + rerender({ + date: newUTC(2023, Month.February, 20, 13, 0, 0), + callback, + }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '01', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'PM', + value: 'PM', + }), + prevSegments: expect.objectContaining({ + hour: '11', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + }); + + describe('America/New_York', () => { + test('24h format', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'America/New_York', + callback, + }); + + const { segments } = result.current; + + // 12:00 UTC = 07:00 EST (UTC-5) + expect(segments.hour).toEqual('07'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + + rerender({ + date: newUTC(2023, Month.February, 20, 13, 0, 0), + callback, + }); + + // 13:00 UTC = 08:00 EST (UTC-5) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '08', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '07', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + test('12h format', () => { + // 16:00 UTC = 11:00 AM EST (UTC-5) + const testDate = newUTC(2023, Month.February, 20, 16, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'America/New_York', + initialLocale: SupportedLocales.en_US, + callback, + }); + + const { segments, selectUnit } = result.current; + + expect(segments.hour).toEqual('11'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + expect(selectUnit).toEqual({ displayName: 'AM', value: 'AM' }); + + rerender({ + date: newUTC(2023, Month.February, 20, 18, 0, 0), + callback, + }); + + // 18:00 UTC = 01:00 PM EST (UTC-5) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '01', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'PM', + value: 'PM', + }), + prevSegments: expect.objectContaining({ + hour: '11', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + }); + + describe('Pacific/Auckland', () => { + test('24h format', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'Pacific/Auckland', + callback, + }); + + const { segments } = result.current; + + // 12:00 UTC = 01:00 NZDT (UTC+13) + expect(segments.hour).toEqual('01'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + + rerender({ + date: newUTC(2023, Month.February, 20, 13, 0, 0), + callback, + }); + + // 13:00 UTC = 02:00 NZDT (UTC+13) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '02', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '01', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + test('12h format', () => { + // 22:00 UTC Feb 20 = 11:00 AM NZDT Feb 21 (UTC+13) + const testDate = newUTC(2023, Month.February, 20, 22, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + initialTimeZone: 'Pacific/Auckland', + initialLocale: SupportedLocales.en_US, + callback, + }); + + const { segments, selectUnit } = result.current; + + expect(segments.hour).toEqual('11'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + expect(selectUnit).toEqual({ displayName: 'AM', value: 'AM' }); + + rerender({ + // 00:00 UTC Feb 21 = 01:00 PM NZDT Feb 21 (UTC+13) + date: newUTC(2023, Month.February, 21, 0, 0, 0), + callback, + }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '01', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'PM', + value: 'PM', + }), + prevSegments: expect.objectContaining({ + hour: '11', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + }); + }); + describe('with a null value', () => { + test('calls callback with empty segments', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + }); + + const { segments } = result.current; + + expect(segments.hour).toEqual('12'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + + rerender({ + date: null, + callback, + }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '', + minute: '', + second: '', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + }); + describe('with an invalid Date value', () => { + test('calls callback with previous segments', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result, rerender } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + }); + + const { segments } = result.current; + + expect(segments.hour).toEqual('12'); + expect(segments.minute).toEqual('00'); + expect(segments.second).toEqual('00'); + + rerender({ + date: new Date('invalid'), + callback, + }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + }); + }); + + describe('setSegment', () => { + test('calls callback when setSegment is called', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + }); + + result.current.setSegment('hour', '13'); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '13', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + }), + ); + }); + }); + + describe('setSelectUnit', () => { + test('calls callback when setSelectUnit is called', () => { + const testDate = newUTC(2023, Month.February, 20, 12, 0, 0); + const callback = jest.fn(); + const { result } = renderUseTimeSegmentsAndSelectUnitHook({ + initialDate: testDate, + callback, + initialLocale: SupportedLocales.en_US, + }); + + result.current.setSelectUnit({ displayName: 'AM', value: 'AM' }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + newSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + newSelectUnit: expect.objectContaining({ + displayName: 'AM', + value: 'AM', + }), + prevSegments: expect.objectContaining({ + hour: '12', + minute: '00', + second: '00', + }), + prevSelectUnit: expect.objectContaining({ + displayName: 'PM', + value: 'PM', + }), + }), + ); + }); + }); +}); diff --git a/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.ts b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.ts new file mode 100644 index 0000000000..1946e0fca6 --- /dev/null +++ b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.ts @@ -0,0 +1,260 @@ +import { useEffect, useReducer } from 'react'; +import isEqual from 'lodash/isEqual'; +import isNull from 'lodash/isNull'; +import isUndefined from 'lodash/isUndefined'; + +import { DateType, isValidDate, LocaleString } from '@leafygreen-ui/date-utils'; +import { usePrevious } from '@leafygreen-ui/hooks'; + +import { unitOptions } from '../../constants'; +import { TimeSegment } from '../../shared.types'; +import { UnitOption } from '../../TimeInputSelect/TimeInputSelect.types'; +import { + findUnitOptionByDayPeriod, + getFormatPartsValues, + getFormattedTimeSegmentsFromDate, + isSameUTCDayAndTime, +} from '../../utils'; + +import { + Action, + ActionKind, + TimeSegmentsAndSelectUnitState, + UseTimeSegmentsOptions, +} from './useTimeSegmentsAndSelectUnit.types'; + +/** + * Reducer for the useTimeSegmentsAndSelect hook + */ +const timeSegmentsAndSelectUnitReducer = ( + currentState: TimeSegmentsAndSelectUnitState, + action: Action, +): TimeSegmentsAndSelectUnitState => { + switch (action.type) { + case ActionKind.UPDATE_TIME_SEGMENTS: + return { + ...currentState, + segments: { + ...currentState.segments, + ...action.payload, + }, + }; + case ActionKind.UPDATE_SELECT_UNIT: + return { + ...currentState, + selectUnit: action.payload, + }; + case ActionKind.UPDATE_TIME_SEGMENTS_AND_SELECT_UNIT: + return { + ...currentState, + segments: { + ...currentState.segments, + ...action.payload.segments, + }, + selectUnit: action.payload.selectUnit, + }; + default: + return currentState; + } +}; + +/** + * Gets the initial state for the useTimeSegmentsAndSelect hook + */ +const getInitialState = ( + date: DateType, + locale: LocaleString, + timeZone: string, +): TimeSegmentsAndSelectUnitState => { + const { dayPeriod } = getFormatPartsValues({ + locale, + timeZone, + value: date, + }); + + const initialSelectUnitOption = findUnitOptionByDayPeriod( + dayPeriod, + unitOptions, + ); + + return { + segments: getFormattedTimeSegmentsFromDate(date, locale, timeZone), + selectUnit: initialSelectUnitOption, + }; +}; + +/** + * Returns an object with all 3 time segments, and a setter function + * + * @param date - The date to use. This is in UTC. + * @param locale - The locale used to format the date. + * @param timeZone - The time zone used to format the date. + * @param options - The options used to configure the hook. + * @param options.onUpdate - The callback to call when the segments or select unit has changed + * + * @returns An object with the segments and select unit relative to the time zone and locale + */ +export const useTimeSegmentsAndSelectUnit = ({ + date = null, + locale, + timeZone, + options: { onUpdate }, +}: { + date?: DateType; + locale: LocaleString; + timeZone: string; + options: UseTimeSegmentsOptions; +}) => { + const [{ segments, selectUnit }, dispatch] = useReducer( + timeSegmentsAndSelectUnitReducer, + date, + date => getInitialState(date, locale, timeZone), + ); + const prevDate = usePrevious(date); + const prevLocale = usePrevious(locale); + const prevTimeZone = usePrevious(timeZone); + + /** + * The useEffect is only to check if the date has changed or if the segments have changed. + * + * If the date is different then we update the segments and call onUpdate. + * + * If the date is the same AND the timezone and locale have not changed then don't update the segments or call onUpdate. This could mean that the user has typed in a new ambiguous value. + * + * If the date is the same BUT the locale or timezone has changed then update the segments but don't call onUpdate because the time did not change. (instead on segmentChange should be called) + * + */ + useEffect(() => { + const isDateValid = isValidDate(date); + const hasDateAndTimeChanged = !isSameUTCDayAndTime(date, prevDate); + const newSegments = getFormattedTimeSegmentsFromDate( + date, + locale, + timeZone, + ); + const hasLocaleChanged = prevLocale !== locale; + const hasTimeZoneChanged = prevTimeZone !== timeZone; + + // If the segments were updated in setSegment then the newSegments should be the same as the segments in state. + const haveSegmentsChanged = !isEqual(newSegments, segments); + + // If the date has been set to null from a previously valid date + const hasTimeBeenCleared = + (isNull(date) || isUndefined(date)) && isValidDate(prevDate); + + // if the date is valid, date has been changed, or the time has been cleared then update the segments and call onUpdate and dispatch + if ((isDateValid && hasDateAndTimeChanged) || hasTimeBeenCleared) { + const { dayPeriod } = getFormatPartsValues({ + locale, + timeZone, + value: date, + }); + + dispatch({ + type: ActionKind.UPDATE_TIME_SEGMENTS_AND_SELECT_UNIT, + payload: { + segments: newSegments, + selectUnit: findUnitOptionByDayPeriod(dayPeriod, unitOptions), + }, + }); + + onUpdate?.({ + newSegments, + prevSegments: { ...segments }, + newSelectUnit: findUnitOptionByDayPeriod(dayPeriod, unitOptions), + prevSelectUnit: selectUnit, + }); + + return; + } + + // if the date is valid and the date has not changed but the segments are different then update the segments and only call dispatch. This means that the user can be typing in an ambiguous value or the locale or timezone has changed. We don't call onUpdate because the date did not change. + if ( + isDateValid && + !hasDateAndTimeChanged && + haveSegmentsChanged && + (hasLocaleChanged || hasTimeZoneChanged) + ) { + const { dayPeriod } = getFormatPartsValues({ + locale, + timeZone, + value: date, + }); + + dispatch({ + type: ActionKind.UPDATE_TIME_SEGMENTS_AND_SELECT_UNIT, + payload: { + segments: newSegments, + selectUnit: findUnitOptionByDayPeriod(dayPeriod, unitOptions), + }, + }); + } + }, [ + date, + locale, + timeZone, + segments, + selectUnit, + onUpdate, + prevDate, + prevLocale, + prevTimeZone, + ]); + + /** + * Sets a segment value. Is called when the user types in a new value. + * + * @param segment - The segment to set + * @param value - The value to set + */ + const setSegment = (segment: TimeSegment, value: string) => { + const updateObject = { [segment]: value }; + + // We need a way to pass the updated segments to onUpdate and update the reducer state at the same time so we manually call the reducer to get the next state. This will not update the reducer state so we still need to dispatch the action to update the reducer state. + const nextState = timeSegmentsAndSelectUnitReducer( + { segments, selectUnit }, + { + type: ActionKind.UPDATE_TIME_SEGMENTS, + payload: updateObject, + }, + ); + // Pass the new state and a copy of the previous state to the callback. This is to ensure that onUpdate gets the latest state. + onUpdate?.({ + newSegments: nextState.segments, + prevSegments: { ...segments }, + newSelectUnit: selectUnit, + prevSelectUnit: selectUnit, + }); + + //This updates the internal state of the hook. + dispatch({ + type: ActionKind.UPDATE_TIME_SEGMENTS, + payload: nextState.segments, + }); + }; + + /** + * Set the select unit and call onUpdate callback if the select unit has changed. + * + * @param selectUnit - The select unit to set + */ + const setSelectUnit = (newSelectUnit: UnitOption) => { + dispatch({ + type: ActionKind.UPDATE_SELECT_UNIT, + payload: newSelectUnit, + }); + + const hasSelectUnitChanged = selectUnit !== newSelectUnit; + + if (hasSelectUnitChanged) { + onUpdate?.({ + newSelectUnit: newSelectUnit, + prevSelectUnit: { ...selectUnit }, + newSegments: { ...segments }, + prevSegments: { ...segments }, + }); + } + }; + + return { segments, setSegment, setSelectUnit, selectUnit }; +}; diff --git a/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.types.ts b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.types.ts new file mode 100644 index 0000000000..480d946df8 --- /dev/null +++ b/packages/time-input/src/hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.types.ts @@ -0,0 +1,47 @@ +import { TimeSegmentsState } from '../../shared.types'; +import { UnitOption } from '../../TimeInputSelect/TimeInputSelect.types'; + +/** + * Callback passed into the hook, called when any segment updates + */ +export type OnUpdateCallback = (params: { + newSegments: TimeSegmentsState; + prevSegments: TimeSegmentsState; + newSelectUnit: UnitOption; + prevSelectUnit: UnitOption; +}) => void; + +/** + * Options for the useTimeSegmentsAndSelect hook + */ +export interface UseTimeSegmentsOptions { + onUpdate?: OnUpdateCallback; +} + +/** + * An enum with all the types of actions to use in our reducer + */ +export enum ActionKind { + UPDATE_TIME_SEGMENTS = 'UPDATE_TIME_SEGMENTS', + UPDATE_SELECT_UNIT = 'UPDATE_SELECT_UNIT', + UPDATE_TIME_SEGMENTS_AND_SELECT_UNIT = 'UPDATE_TIME_SEGMENTS_AND_SELECT_UNIT', +} + +/** + * An interface for our actions + */ +export type Action = + | { + type: ActionKind.UPDATE_TIME_SEGMENTS; + payload: Partial; + } + | { type: ActionKind.UPDATE_SELECT_UNIT; payload: UnitOption } + | { + type: ActionKind.UPDATE_TIME_SEGMENTS_AND_SELECT_UNIT; + payload: { segments: Partial; selectUnit: UnitOption }; + }; + +export interface TimeSegmentsAndSelectUnitState { + segments: TimeSegmentsState; + selectUnit: UnitOption; +}