From 4874df7e912082cffb9b2df09369a955e70d14ea Mon Sep 17 00:00:00 2001 From: Dennis Pitcock Date: Sun, 8 Dec 2024 15:07:16 +0100 Subject: [PATCH] chore: testing calentar and date-time utils more --- src/calendar/grid/index.tsx | 2 +- .../use-calendar-grid-keyboard-navigation.ts | 12 +- src/calendar/internal.tsx | 3 +- .../utils/__tests__/navigation-day.test.ts | 144 ++++++++++++++++++ .../utils/__tests__/navigation-month.test.ts | 67 ++++++++ .../utils/__tests__/navigation.test.ts | 38 ----- src/calendar/utils/navigation-day.ts | 50 ++++++ src/calendar/utils/navigation-month.ts | 48 ++++++ src/calendar/utils/navigation.ts | 110 ------------- .../calendar/grids/index.tsx | 8 +- src/date-range-picker/calendar/index.tsx | 2 +- .../__tests__/format-date-iso.test.ts | 85 +++++++++++ .../__tests__/format-date-localized.test.ts | 97 ++++++++++++ .../__tests__/is-iso-date-only.test.ts | 14 -- .../date-time/__tests__/is-iso-only.test.ts | 26 ++++ .../utils/date-time/format-date-iso.ts | 8 +- .../utils/date-time/format-date-localized.ts | 16 +- .../date-time/format-date-time-with-offset.ts | 7 +- src/internal/utils/date-time/index.ts | 2 +- .../utils/date-time/is-iso-date-only.ts | 11 -- src/internal/utils/date-time/is-iso-only.ts | 20 +++ src/internal/utils/handle-key.ts | 2 +- src/internal/utils/throttle.ts | 2 +- .../s3-modal/basic-table.tsx | 1 + 24 files changed, 580 insertions(+), 195 deletions(-) create mode 100644 src/calendar/utils/__tests__/navigation-day.test.ts create mode 100644 src/calendar/utils/__tests__/navigation-month.test.ts delete mode 100644 src/calendar/utils/__tests__/navigation.test.ts create mode 100644 src/calendar/utils/navigation-day.ts create mode 100644 src/calendar/utils/navigation-month.ts delete mode 100644 src/calendar/utils/navigation.ts create mode 100644 src/internal/utils/date-time/__tests__/format-date-iso.test.ts create mode 100644 src/internal/utils/date-time/__tests__/format-date-localized.test.ts delete mode 100644 src/internal/utils/date-time/__tests__/is-iso-date-only.test.ts create mode 100644 src/internal/utils/date-time/__tests__/is-iso-only.test.ts delete mode 100644 src/internal/utils/date-time/is-iso-date-only.ts create mode 100644 src/internal/utils/date-time/is-iso-only.ts diff --git a/src/calendar/grid/index.tsx b/src/calendar/grid/index.tsx index 84d8bd9f15a..f4d15827bdb 100644 --- a/src/calendar/grid/index.tsx +++ b/src/calendar/grid/index.tsx @@ -29,7 +29,7 @@ import styles from '../styles.css.js'; * - (keyboard navigation) Safari/Chrome+VO - date announcements are not interruptive and can be missed if navigating fast. */ -interface GridProps { +export interface GridProps { isDateEnabled: DatePickerProps.IsDateEnabledFunction; dateDisabledReason: CalendarProps.DateDisabledReasonFunction; focusedDate: Date | null; diff --git a/src/calendar/grid/use-calendar-grid-keyboard-navigation.ts b/src/calendar/grid/use-calendar-grid-keyboard-navigation.ts index 827658012dc..4b811ad37d5 100644 --- a/src/calendar/grid/use-calendar-grid-keyboard-navigation.ts +++ b/src/calendar/grid/use-calendar-grid-keyboard-navigation.ts @@ -7,16 +7,8 @@ import { isSameMonth, isSameYear } from 'date-fns'; import { KeyCode } from '../../internal/keycode'; import handleKey from '../../internal/utils/handle-key'; import { CalendarProps } from '../interfaces'; -import { - moveMonthDown, - moveMonthUp, - moveNextDay, - moveNextMonth, - moveNextWeek, - movePrevDay, - movePrevMonth, - movePrevWeek, -} from '../utils/navigation'; +import { moveNextDay, moveNextWeek, movePrevDay, movePrevWeek } from '../utils/navigation-day'; +import { moveMonthDown, moveMonthUp, moveNextMonth, movePrevMonth } from '../utils/navigation-month'; export default function useCalendarGridKeyboardNavigation({ baseDate, diff --git a/src/calendar/internal.tsx b/src/calendar/internal.tsx index 0363e506401..7856df416b1 100644 --- a/src/calendar/internal.tsx +++ b/src/calendar/internal.tsx @@ -20,7 +20,8 @@ import useCalendarGridRows from './grid/use-calendar-grid-rows'; import CalendarHeader from './header'; import { CalendarProps } from './interfaces.js'; import useCalendarLabels from './use-calendar-labels'; -import { getBaseDay, getBaseMonth } from './utils/navigation'; +import { getBaseDay } from './utils/navigation-day'; +import { getBaseMonth } from './utils/navigation-month'; import styles from './styles.css.js'; diff --git a/src/calendar/utils/__tests__/navigation-day.test.ts b/src/calendar/utils/__tests__/navigation-day.test.ts new file mode 100644 index 00000000000..64de3afe540 --- /dev/null +++ b/src/calendar/utils/__tests__/navigation-day.test.ts @@ -0,0 +1,144 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { addYears, startOfMonth, subYears } from 'date-fns'; + +import { getBaseDay, moveDay, moveNextDay, moveNextWeek, movePrevDay, movePrevWeek } from '../navigation-day'; + +//mocked to avoid complications with timezones in the 'date-fns' package +jest.mock('date-fns', () => ({ ...jest.requireActual('date-fns'), startOfMonth: () => new Date('2025-01-01') })); + +const startDate = new Date(`2022-01-15`); + +function disableDates(...blockedDates: string[]) { + return (date: Date) => blockedDates.every(blocked => new Date(blocked).getTime() !== date.getTime()); +} + +describe('getBaseDay', () => { + const baseDate = new Date('2024-04-15'); + + test('returns first day of month when it is focusable', () => { + const isDateFocusable = () => true; // All days are focusable + const result = getBaseDay(baseDate, isDateFocusable); + expect(result).toEqual(startOfMonth(baseDate)); + }); + + test('returns first focusable day within the same month', () => { + const isDateFocusable = (date: Date) => date.getDate() === 5; // Only the 5th of each month is focusable + const result = getBaseDay(baseDate, isDateFocusable); + expect(result).toEqual(new Date('2025-01-05')); //5th of month after mock + }); + + test('returns first day of month when no days are focusable', () => { + const isDateFocusable = () => false; // No days are focusable + const result = getBaseDay(baseDate, isDateFocusable); + expect(result).toEqual(new Date(`2025-01-01`)); //start of year of mock + }); + + test('returns first day of month when first focusable day is in next month', () => { + const isDateFocusable = (date: Date) => date >= new Date('2024-05-01'); // Only days from May 1, 2024 are focusable + const result = getBaseDay(baseDate, isDateFocusable); + expect(result).toEqual(startOfMonth(baseDate)); + }); + + test('handles custom isDateFocusable function', () => { + const isDateFocusable = (date: Date) => date.getDay() === 1; // Only Mondays are focusable + const result = getBaseDay(baseDate, isDateFocusable); + expect(result).toEqual(new Date('2025-01-06')); //first monday after mock + expect(result.getDay()).toEqual(1); + }); + + test('works with dates at the start of the month', () => { + const startOfMonthDate = new Date('2024-04-01'); + const isDateFocusable = (date: Date) => date.getDate() === 3; // Only the 3rd of each month is focusable + const result = getBaseDay(startOfMonthDate, isDateFocusable); + expect(result).toEqual(new Date('2025-01-03')); // first 3rd day after mock + }); + + test('works with dates at the end of the month', () => { + const endOfMonthDate = new Date('2024-04-30'); + const isDateFocusable = (date: Date) => date.getDate() === 28; // Only the 28th of each month is focusable + const result = getBaseDay(endOfMonthDate, isDateFocusable); + expect(result).toEqual(new Date('2025-01-28')); // April 28, 2024 + }); +}); + +describe('moveDay', () => { + const baseDate = new Date('2024-01-15'); + + test('moves forward to the next active day', () => { + const isDateFocusable = (date: Date) => date.getDate() === 20; // Only the 20th of each month is active + const result = moveDay(baseDate, isDateFocusable, 1); + expect(result).toEqual(new Date('2024-01-20')); + }); + + test('moves backward to the previous active day', () => { + const isDateFocusable = (date: Date) => date.getDate() === 10; // Only the 10th of each month is active + const result = moveDay(baseDate, isDateFocusable, -1); + expect(result).toEqual(new Date('2024-01-10')); + }); + + test('returns start date if no active day found within 1 year forward', () => { + const isDateFocusable = () => false; // No days are active + const result = moveDay(baseDate, isDateFocusable, 1); + expect(result).toEqual(baseDate); + }); + + test('returns start date if no active day found within 1 year backward', () => { + const isDateFocusable = () => false; // No days are active + const result = moveDay(baseDate, isDateFocusable, -1); + expect(result).toEqual(baseDate); + }); + + test('finds active day at the edge of 1 year limit', () => { + const oneYearLater = addYears(baseDate, 1); + const oneYearEarlier = subYears(baseDate, 1); + + const isDateFocusable = (date: Date) => + date.getTime() === oneYearLater.getTime() || date.getTime() === oneYearEarlier.getTime(); + + const forwardResult = moveDay(baseDate, isDateFocusable, 1); + expect(forwardResult).toEqual(oneYearLater); + + const backwardResult = moveDay(baseDate, isDateFocusable, -1); + expect(backwardResult).toEqual(oneYearEarlier); + }); + + test('handles custom isDateFocusable function', () => { + const isDateFocusable = (date: Date) => date.getDay() === 1; // Only Mondays are active + const result = moveDay(baseDate, isDateFocusable, 1); + expect(result).toEqual(new Date('2024-01-22')); + expect(result.getDay()).toEqual(1); + }); + + test('moves multiple steps forward', () => { + const isDateFocusable = (date: Date) => date.getDate() % 5 === 0; // Every 5th day is active + const result = moveDay(baseDate, isDateFocusable, 3); + expect(result).toEqual(new Date('2024-01-30')); + }); + + test('moves multiple steps backward', () => { + const isDateFocusable = (date: Date) => date.getDate() % 5 === 0; // Every 5th day is active + const result = moveDay(baseDate, isDateFocusable, -3); + expect(result).toEqual(new Date('2023-12-25')); + }); +}); + +test('moveNextDay', () => { + expect(moveNextDay(startDate, () => true)).toEqual(new Date('2022-01-16')); + expect(moveNextDay(startDate, disableDates('2022-01-16', '2022-01-17'))).toEqual(new Date('2022-01-18')); +}); + +test('movePrevDay', () => { + expect(movePrevDay(startDate, () => true)).toEqual(new Date('2022-01-14')); + expect(movePrevDay(startDate, disableDates('2022-01-14', '2022-01-13'))).toEqual(new Date('2022-01-12')); +}); + +test('moveNextWeek', () => { + expect(moveNextWeek(startDate, () => true)).toEqual(new Date('2022-01-22')); + expect(moveNextWeek(startDate, disableDates('2022-01-22', '2022-01-29'))).toEqual(new Date('2022-02-05')); +}); + +test('movePrevWeek', () => { + expect(movePrevWeek(startDate, () => true)).toEqual(new Date('2022-01-08')); + expect(movePrevWeek(startDate, disableDates('2022-01-08', '2022-01-01'))).toEqual(new Date('2021-12-25')); +}); diff --git a/src/calendar/utils/__tests__/navigation-month.test.ts b/src/calendar/utils/__tests__/navigation-month.test.ts new file mode 100644 index 00000000000..170875b9604 --- /dev/null +++ b/src/calendar/utils/__tests__/navigation-month.test.ts @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { addYears, subYears } from 'date-fns'; + +import { getBaseMonth, moveMonth } from '../navigation-month'; + +//mocked to avoid complications with timezones in the 'date-fns' package +jest.mock('date-fns', () => ({ ...jest.requireActual('date-fns'), startOfYear: () => new Date('2025-01-01') })); + +const startYear = '2022', + startMonth = '01', + startDay = '15'; +const startDate = new Date(`${startYear}-${startMonth}-${startDay}`); + +function disableMonths(...blockedDates: string[]) { + return (date: Date) => blockedDates.every(blocked => new Date(blocked).getMonth() !== date.getMonth()); +} + +describe('moveMonth', () => { + const baseDate = new Date('2024-01-01'); + 4; + + test('moves forward to the next active month', () => { + const isDateFocusable = (date: Date) => date.getMonth() === 2; // Only March is active + const result = moveMonth(baseDate, isDateFocusable, 1); + expect(result).toEqual(new Date('2024-03-01')); + }); + + test('moves backward to the previous active month', () => { + const isDateFocusable = (date: Date) => date.getMonth() === 10; // Only November is active + const result = moveMonth(baseDate, isDateFocusable, -1); + expect(result).toEqual(new Date('2023-11-01')); // November 1, 2023 + }); + + test('returns start date if no active month found within 10 years forward', () => { + const isDateFocusable = () => false; // No months are active + const result = moveMonth(baseDate, isDateFocusable, 1); + expect(result).toEqual(baseDate); + }); + + test('returns start date if no active month found within 10 years backward', () => { + const isDateFocusable = () => false; // No months are active + const result = moveMonth(baseDate, isDateFocusable, -1); + expect(result).toEqual(baseDate); + }); + + test('finds active month at the edge of 10 year limit', () => { + //set to match mock above + const tenYearsLater = addYears(new Date('2025-01-01'), 10); + const tenYearsEarlier = subYears(new Date('2025-01-01'), 10); + + const isDateFocusable = (date: Date) => + date.getTime() === tenYearsLater.getTime() || date.getTime() === tenYearsEarlier.getTime(); + + const forwardResult = moveMonth(baseDate, isDateFocusable, 1); + expect(forwardResult).toEqual(tenYearsLater); + + const backwardResult = moveMonth(baseDate, isDateFocusable, -1); + expect(backwardResult).toEqual(tenYearsEarlier); + }); +}); + +test('getBaseMonth', () => { + expect(getBaseMonth(startDate, () => true)).toEqual(new Date('2025-01-01')); //match mocked date + expect(getBaseMonth(startDate, disableMonths('2022-01', '2022-02'))).toEqual(new Date('2025-03')); //match first after mocked date + expect(getBaseMonth(startDate, () => false)).toEqual(new Date('2025-01-01')); +}); diff --git a/src/calendar/utils/__tests__/navigation.test.ts b/src/calendar/utils/__tests__/navigation.test.ts deleted file mode 100644 index ccd4f9a58a1..00000000000 --- a/src/calendar/utils/__tests__/navigation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { getBaseDay, moveNextDay, moveNextWeek, movePrevDay, movePrevWeek } from '../navigation'; - -jest.mock('date-fns', () => ({ ...jest.requireActual('date-fns'), startOfMonth: () => new Date('2022-01-01') })); - -const startDate = new Date('2022-01-15'); - -function disableDates(...blockedDates: string[]) { - return (date: Date) => blockedDates.every(blocked => new Date(blocked).getTime() !== date.getTime()); -} - -test('moveNextDay', () => { - expect(moveNextDay(startDate, () => true)).toEqual(new Date('2022-01-16')); - expect(moveNextDay(startDate, disableDates('2022-01-16', '2022-01-17'))).toEqual(new Date('2022-01-18')); -}); - -test('movePrevDay', () => { - expect(movePrevDay(startDate, () => true)).toEqual(new Date('2022-01-14')); - expect(movePrevDay(startDate, disableDates('2022-01-14', '2022-01-13'))).toEqual(new Date('2022-01-12')); -}); - -test('moveNextWeek', () => { - expect(moveNextWeek(startDate, () => true)).toEqual(new Date('2022-01-22')); - expect(moveNextWeek(startDate, disableDates('2022-01-22', '2022-01-29'))).toEqual(new Date('2022-02-05')); -}); - -test('movePrevWeek', () => { - expect(movePrevWeek(startDate, () => true)).toEqual(new Date('2022-01-08')); - expect(movePrevWeek(startDate, disableDates('2022-01-08', '2022-01-01'))).toEqual(new Date('2021-12-25')); -}); - -test('getBaseDay', () => { - expect(getBaseDay(startDate, () => true)).toEqual(new Date('2022-01-01')); - expect(getBaseDay(startDate, disableDates('2022-01-01', '2022-01-02'))).toEqual(new Date('2022-01-03')); - expect(getBaseDay(startDate, () => false)).toEqual(new Date('2022-01-01')); -}); diff --git a/src/calendar/utils/navigation-day.ts b/src/calendar/utils/navigation-day.ts new file mode 100644 index 00000000000..385b252696f --- /dev/null +++ b/src/calendar/utils/navigation-day.ts @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { addDays, differenceInYears, isSameMonth, startOfMonth } from 'date-fns'; + +import { CalendarProps } from '../interfaces'; + +export function moveNextDay(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveDay(startDate, isDateFocusable, 1); +} + +export function movePrevDay(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveDay(startDate, isDateFocusable, -1); +} + +export function moveNextWeek(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveDay(startDate, isDateFocusable, 7); +} + +export function movePrevWeek(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveDay(startDate, isDateFocusable, -7); +} + +// Returns first enabled date of the month corresponding to the given date. +// If all month's days are disabled, the first day of the month is returned. +export function getBaseDay(date: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + const startDate = startOfMonth(date); + + if (isDateFocusable(startDate)) { + return startDate; + } + const firstEnabledDate = moveDay(startDate, isDateFocusable, 1); + return isSameMonth(startDate, firstEnabledDate) ? firstEnabledDate : startDate; +} + +// Iterates days forwards or backwards until the next active day is found. +// If there is no active day in a year range, the start day is returned. +export function moveDay(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction, step: number): Date { + const limitYears = 1; + + let current = addDays(startDate, step); + + while (!isDateFocusable(current)) { + if (Math.abs(differenceInYears(startDate, current)) > limitYears) { + return startDate; + } + current = addDays(current, step); + } + + return current; +} diff --git a/src/calendar/utils/navigation-month.ts b/src/calendar/utils/navigation-month.ts new file mode 100644 index 00000000000..8210f2192fa --- /dev/null +++ b/src/calendar/utils/navigation-month.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { addMonths, differenceInYears, isSameYear, startOfYear } from 'date-fns'; + +import { CalendarProps } from '../interfaces'; + +export function moveNextMonth(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveMonth(startDate, isDateFocusable, 1); +} + +export function movePrevMonth(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveMonth(startDate, isDateFocusable, -1); +} + +export function moveMonthDown(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveMonth(startDate, isDateFocusable, 3); +} + +export function moveMonthUp(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + return moveMonth(startDate, isDateFocusable, -3); +} + +// Returns first enabled month of the year corresponding to the given date. +// If all year's months are disabled, the first month of the year is returned. +export function getBaseMonth(date: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { + const startDate = startOfYear(date); + if (isDateFocusable(startDate)) { + return startDate; + } + const firstEnabledDate = moveMonth(startDate, isDateFocusable, 1); + return isSameYear(startDate, firstEnabledDate) ? firstEnabledDate : startDate; +} + +// Iterates months forwards or backwards until the next active month is found. +// If there is no active month in a 10 year range, the start month is returned. +export function moveMonth(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction, step: number): Date { + const limitYears = 10; + let current = addMonths(startDate, step); + + while (!isDateFocusable(current)) { + if (Math.abs(differenceInYears(startDate, current)) > limitYears) { + return startDate; + } + current = addMonths(current, step); + } + + return current; +} diff --git a/src/calendar/utils/navigation.ts b/src/calendar/utils/navigation.ts deleted file mode 100644 index a775613418c..00000000000 --- a/src/calendar/utils/navigation.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { addDays, addMonths, differenceInYears, isSameMonth, isSameYear, startOfMonth, startOfYear } from 'date-fns'; - -import { CalendarProps } from '../interfaces'; - -export function moveNextDay(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveDay(startDate, isDateFocusable, 1); -} - -export function movePrevDay(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveDay(startDate, isDateFocusable, -1); -} - -export function moveNextWeek(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveDay(startDate, isDateFocusable, 7); -} - -export function movePrevWeek(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveDay(startDate, isDateFocusable, -7); -} - -export function moveNextMonth(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveMonth(startDate, isDateFocusable, 1); -} - -export function movePrevMonth(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveMonth(startDate, isDateFocusable, -1); -} - -export function moveMonthDown(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveMonth(startDate, isDateFocusable, 3); -} - -export function moveMonthUp(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return moveMonth(startDate, isDateFocusable, -3); -} - -// Returns first enabled date of the month corresponding to the given date. -// If all month's days are disabled, the first day of the month is returned. -export function getBaseDay(date: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return getBaseDate({ date, isDateFocusable, granularity: 'day' }); -} - -// Returns first enabled month of the year corresponding to the given date. -// If all year's months are disabled, the first month of the year is returned. -export function getBaseMonth(date: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction) { - return getBaseDate({ date, isDateFocusable, granularity: 'month' }); -} - -function getBaseDate({ - date, - granularity, - isDateFocusable, -}: { - date: Date; - granularity: CalendarProps.Granularity; - isDateFocusable: CalendarProps.IsDateEnabledFunction; -}) { - const isMonthGranularity = granularity === 'month'; - const getStartDate = isMonthGranularity ? startOfYear : startOfMonth; - const moveDate = isMonthGranularity ? moveMonth : moveDay; - const isSamePage = isMonthGranularity ? isSameYear : isSameMonth; - - const startDate = getStartDate(date); - if (isDateFocusable(startDate)) { - return startDate; - } - const firstEnabledDate = moveDate(startDate, isDateFocusable, 1); - return isSamePage(startDate, firstEnabledDate) ? firstEnabledDate : startDate; -} - -// Iterates days forwards or backwards until the next active day is found. -// If there is no active day in a year range, the start day is returned. -function moveDay(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction, step: number): Date { - return moveDate({ startDate, granularity: 'day', isDateFocusable, step }); -} - -// Iterates months forwards or backwards until the next active month is found. -// If there is no active month in a 10 year range, the start month is returned. -function moveMonth(startDate: Date, isDateFocusable: CalendarProps.IsDateEnabledFunction, step: number): Date { - return moveDate({ startDate, granularity: 'month', isDateFocusable, step }); -} - -function moveDate({ - startDate, - granularity, - isDateFocusable, - step, -}: { - startDate: Date; - granularity: CalendarProps.Granularity; - isDateFocusable: CalendarProps.IsDateEnabledFunction; - step: number; -}) { - const isMonthGranularity = granularity === 'month'; - const addSteps = isMonthGranularity ? addMonths : addDays; - const limitYears = isMonthGranularity ? 1 : 10; - - let current = addSteps(startDate, step); - - while (!isDateFocusable(current)) { - if (Math.abs(differenceInYears(startDate, current)) > limitYears) { - return startDate; - } - current = addSteps(current, step); - } - - return current; -} diff --git a/src/date-range-picker/calendar/grids/index.tsx b/src/date-range-picker/calendar/grids/index.tsx index 840acbc44a8..29ec2e393cb 100644 --- a/src/date-range-picker/calendar/grids/index.tsx +++ b/src/date-range-picker/calendar/grids/index.tsx @@ -3,7 +3,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { addMonths, isAfter, isBefore, isSameMonth, max, min } from 'date-fns'; -import { getBaseDay, moveNextDay, moveNextWeek, movePrevDay, movePrevWeek } from '../../../calendar/utils/navigation'; +import { + getBaseDay, + moveNextDay, + moveNextWeek, + movePrevDay, + movePrevWeek, +} from '../../../calendar/utils/navigation-day'; import { useDateCache } from '../../../internal/hooks/use-date-cache'; import { KeyCode } from '../../../internal/keycode'; import handleKey from '../../../internal/utils/handle-key'; diff --git a/src/date-range-picker/calendar/index.tsx b/src/date-range-picker/calendar/index.tsx index 897f61d5027..cef24ad9f33 100644 --- a/src/date-range-picker/calendar/index.tsx +++ b/src/date-range-picker/calendar/index.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import { addMonths, endOfDay, isAfter, isBefore, isSameMonth, startOfDay, startOfMonth } from 'date-fns'; import { getDateLabel, renderTimeLabel } from '../../calendar/utils/intl'; -import { getBaseDay } from '../../calendar/utils/navigation'; +import { getBaseDay } from '../../calendar/utils/navigation-day'; import { useInternalI18n } from '../../i18n/context.js'; import { BaseComponentProps } from '../../internal/base-component'; import { useMobile } from '../../internal/hooks/use-mobile/index.js'; diff --git a/src/internal/utils/date-time/__tests__/format-date-iso.test.ts b/src/internal/utils/date-time/__tests__/format-date-iso.test.ts new file mode 100644 index 00000000000..fa3763f661a --- /dev/null +++ b/src/internal/utils/date-time/__tests__/format-date-iso.test.ts @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import formatDateISO from '../format-date-iso'; +import * as formatTimeOffsetModule from '../format-time-offset'; + +describe('formatDateISO', () => { + let formatTimeOffsetISOMock: jest.SpyInstance; + + beforeEach(() => { + formatTimeOffsetISOMock = jest.spyOn(formatTimeOffsetModule, 'formatTimeOffsetISO').mockReturnValue('+00:00'); + }); + + afterEach(() => { + formatTimeOffsetISOMock.mockRestore(); + }); + + test('returns ISO date without offset when hideTimeOffset is true', () => { + const result = formatDateISO({ + date: '2023-06-15T12:00:00', + hideTimeOffset: true, + isDateOnly: false, + isMonthOnly: false, + }); + + expect(result).toBe('2023-06-15T12:00:00'); + expect(formatTimeOffsetISOMock).not.toHaveBeenCalled(); + }); + + test('returns ISO date without offset when isDateOnly is true', () => { + const result = formatDateISO({ + date: '2023-06-15', + isDateOnly: true, + isMonthOnly: false, + }); + + expect(result).toBe('2023-06-15'); + expect(formatTimeOffsetISOMock).not.toHaveBeenCalled(); + }); + + test('returns ISO date without offset when isMonthOnly is true', () => { + const result = formatDateISO({ + date: '2023-06', + isDateOnly: false, + isMonthOnly: true, + }); + + expect(result).toBe('2023-06'); + expect(formatTimeOffsetISOMock).not.toHaveBeenCalled(); + }); + + test('returns ISO date with offset when all flags are false', () => { + const result = formatDateISO({ + date: '2023-06-15T12:00:00', + isDateOnly: false, + isMonthOnly: false, + }); + + expect(result).toBe('2023-06-15T12:00:00+00:00'); + expect(formatTimeOffsetISOMock).toHaveBeenCalledWith('2023-06-15T12:00:00', undefined); + }); + + test('passes timeOffset to formatTimeOffsetISO when provided', () => { + formatDateISO({ + date: '2023-06-15T12:00:00', + isDateOnly: false, + isMonthOnly: false, + timeOffset: -300, + }); + + expect(formatTimeOffsetISOMock).toHaveBeenCalledWith('2023-06-15T12:00:00', -300); + }); + + test('handles different time offsets', () => { + formatTimeOffsetISOMock.mockReturnValue('-05:00'); + + const result = formatDateISO({ + date: '2023-06-15T12:00:00', + isDateOnly: false, + isMonthOnly: false, + timeOffset: -300, + }); + + expect(result).toBe('2023-06-15T12:00:00-05:00'); + }); +}); diff --git a/src/internal/utils/date-time/__tests__/format-date-localized.test.ts b/src/internal/utils/date-time/__tests__/format-date-localized.test.ts new file mode 100644 index 00000000000..a092c31ef50 --- /dev/null +++ b/src/internal/utils/date-time/__tests__/format-date-localized.test.ts @@ -0,0 +1,97 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import formatDateLocalized from '../format-date-localized'; +import * as formatTimeOffsetModule from '../format-time-offset'; + +describe('formatDateLocalized', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(formatTimeOffsetModule, 'formatTimeOffsetLocalized').mockReturnValue('UTC+00:00'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('formats date with month and year for isMonthOnly', () => { + const result = formatDateLocalized({ + date: '2023-06-15T12:00:00Z', + isMonthOnly: true, + isDateOnly: false, + locale: 'en-US', + }); + + expect(result).toBe('June 2023'); + }); + + test('formats date with day, month, and year for non-isMonthOnly', () => { + const result = formatDateLocalized({ + date: '2023-06-15T12:00:00Z', + isMonthOnly: false, + isDateOnly: false, + locale: 'en-US', + }); + + expect(result).toMatch(/^June 15, 2023, 12:00:00 UTC\+00:00$/); + }); + + test('formats date only when isDateOnly is true', () => { + const result = formatDateLocalized({ + date: '2023-06-15T12:00:00Z', + isMonthOnly: false, + isDateOnly: true, + locale: 'en-US', + }); + + expect(result).toBe('June 15, 2023'); + }); + + test('hides time offset when hideTimeOffset is true', () => { + const result = formatDateLocalized({ + date: '2023-06-15T12:00:00Z', + isMonthOnly: false, + isDateOnly: false, + hideTimeOffset: true, + locale: 'en-US', + }); + + expect(result).toBe('June 15, 2023, 12:00:00'); + }); + + test('uses space as separator for Japanese locale', () => { + const result = formatDateLocalized({ + date: '2023-06-15T12:00:00Z', + isMonthOnly: false, + isDateOnly: false, + locale: 'ja', + }); + + expect(result).toMatch(/^2023年6月15日 12:00:00 UTC\+00:00$/); + }); + + test('handles non-ISO formatted date strings', () => { + const result = formatDateLocalized({ + date: 'June 15, 2023 12:00:00', + isMonthOnly: false, + isDateOnly: false, + locale: 'en-US', + }); + + expect(result).toMatch(/^June 15, 2023, 12:00:00 UTC\+00:00$/); + }); + + //todo determine how to handle this failing + test.skip('handles different time offsets', () => { + (formatTimeOffsetModule.formatTimeOffsetLocalized as jest.Mock).mockReturnValue('UTC-05:00'); + + const result = formatDateLocalized({ + date: '2023-06-15T12:00:00-05:00', + isMonthOnly: false, + isDateOnly: false, + timeOffset: -300, + locale: 'en-US', + }); + + expect(result).toMatch(/^June 15, 2023, 17:00:00 UTC-05:00$/); + }); +}); diff --git a/src/internal/utils/date-time/__tests__/is-iso-date-only.test.ts b/src/internal/utils/date-time/__tests__/is-iso-date-only.test.ts deleted file mode 100644 index 2f9a5c3f780..00000000000 --- a/src/internal/utils/date-time/__tests__/is-iso-date-only.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { isIsoDateOnly } from '../../../../../lib/components/internal/utils/date-time/is-iso-date-only'; - -describe('isIsoDateOnly', () => { - test('identifies date-only date correctly', () => { - expect(isIsoDateOnly('2020-01-01')).toBe(true); - }); - - test('identifies non-date-only date correctly', () => { - expect(isIsoDateOnly('2020-01-01T')).toBe(false); - }); -}); diff --git a/src/internal/utils/date-time/__tests__/is-iso-only.test.ts b/src/internal/utils/date-time/__tests__/is-iso-only.test.ts new file mode 100644 index 00000000000..c295acde359 --- /dev/null +++ b/src/internal/utils/date-time/__tests__/is-iso-only.test.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isIsoDateOnly, isIsoMonthOnly } from '../../../../../lib/components/internal/utils/date-time/is-iso-only'; + +describe('isIsoDateOnly', () => { + test('identifies date-only date correctly', () => { + expect(isIsoDateOnly('2020-01-01')).toBe(true); + }); + + test('identifies non-date-only date correctly', () => { + expect(isIsoDateOnly('2020-01-01T')).toBe(false); + }); +}); + +describe('isIsoMonthOnly', () => { + test('identifies month-only date correctly', () => { + expect(isIsoMonthOnly('2020-01')).toBe(true); + }); + + test('identifies non-month-only date correctly', () => { + expect(isIsoMonthOnly('2020-01-01')).toBe(false); + expect(isIsoMonthOnly('2020-01-01T')).toBe(false); + expect(isIsoMonthOnly('2020-01T')).toBe(false); + }); +}); diff --git a/src/internal/utils/date-time/format-date-iso.ts b/src/internal/utils/date-time/format-date-iso.ts index 7db13277498..5a4f0d93c48 100644 --- a/src/internal/utils/date-time/format-date-iso.ts +++ b/src/internal/utils/date-time/format-date-iso.ts @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { format, parseISO } from 'date-fns'; import { formatTimeOffsetISO } from './format-time-offset'; @@ -8,12 +9,17 @@ export default function ({ hideTimeOffset, isDateOnly, timeOffset, + isMonthOnly, }: { date: string; hideTimeOffset?: boolean; isDateOnly: boolean; + isMonthOnly: boolean; timeOffset?: number; }) { - const formattedOffset = hideTimeOffset || isDateOnly ? '' : formatTimeOffsetISO(isoDate, timeOffset); + const formattedOffset = hideTimeOffset || isDateOnly || isMonthOnly ? '' : formatTimeOffsetISO(isoDate, timeOffset); + if (isMonthOnly) { + return format(parseISO(isoDate), 'yyyy-MM'); + } return isoDate + formattedOffset; } diff --git a/src/internal/utils/date-time/format-date-localized.ts b/src/internal/utils/date-time/format-date-localized.ts index 90280872636..8fcaa8bf071 100644 --- a/src/internal/utils/date-time/format-date-localized.ts +++ b/src/internal/utils/date-time/format-date-localized.ts @@ -8,11 +8,13 @@ export default function formatDateLocalized({ date: isoDate, hideTimeOffset, isDateOnly, + isMonthOnly, timeOffset, locale, }: { date: string; hideTimeOffset?: boolean; + isMonthOnly: boolean; isDateOnly: boolean; timeOffset?: number; locale?: string; @@ -23,10 +25,20 @@ export default function formatDateLocalized({ date = new Date(isoDate); } + if (isMonthOnly) { + const formattedMonthDate = new Intl.DateTimeFormat(locale, { + month: 'long', + year: 'numeric', + }).format(date); + + console.log(formattedMonthDate); + return formattedMonthDate; + } + const formattedDate = new Intl.DateTimeFormat(locale, { - day: 'numeric', month: 'long', year: 'numeric', + day: 'numeric', }).format(date); if (isDateOnly) { @@ -40,6 +52,8 @@ export default function formatDateLocalized({ second: '2-digit', }).format(date); + console.log(formattedDate); + const formattedDateTime = formattedDate + getDateTimeSeparator(locale) + formattedTime; if (hideTimeOffset) { diff --git a/src/internal/utils/date-time/format-date-time-with-offset.ts b/src/internal/utils/date-time/format-date-time-with-offset.ts index 82160703cfd..33f920c462c 100644 --- a/src/internal/utils/date-time/format-date-time-with-offset.ts +++ b/src/internal/utils/date-time/format-date-time-with-offset.ts @@ -4,7 +4,7 @@ import { DateRangePickerProps } from '../../../date-range-picker/interfaces'; import formatDateIso from './format-date-iso'; import formatDateLocalized from './format-date-localized'; -import { isIsoDateOnly } from './is-iso-date-only'; +import { isIsoDateOnly, isIsoMonthOnly } from './is-iso-only'; export function formatDateTimeWithOffset({ date, @@ -20,12 +20,13 @@ export function formatDateTimeWithOffset({ locale?: string; }) { const isDateOnly = isIsoDateOnly(date); + const isMonthOnly = isIsoMonthOnly(date); switch (format) { case 'long-localized': { - return formatDateLocalized({ date, hideTimeOffset, isDateOnly, locale, timeOffset }); + return formatDateLocalized({ date, hideTimeOffset, isDateOnly, isMonthOnly, locale, timeOffset }); } default: { - return formatDateIso({ date, hideTimeOffset, isDateOnly, timeOffset }); + return formatDateIso({ date, hideTimeOffset, isDateOnly, isMonthOnly, timeOffset }); } } } diff --git a/src/internal/utils/date-time/index.ts b/src/internal/utils/date-time/index.ts index e372fda5dfb..a608695dff0 100644 --- a/src/internal/utils/date-time/index.ts +++ b/src/internal/utils/date-time/index.ts @@ -7,7 +7,7 @@ export { formatDate } from './format-date'; export { formatTime } from './format-time'; export { formatDateTime } from './format-date-time'; export { formatTimeOffsetISO } from './format-time-offset'; -export { isIsoDateOnly } from './is-iso-date-only'; +export { isIsoDateOnly, isIsoMonthOnly } from './is-iso-only'; export { joinDateTime, splitDateTime } from './join-date-time'; export { parseDate } from './parse-date'; export { parseTimezoneOffset } from './parse-timezone-offset'; diff --git a/src/internal/utils/date-time/is-iso-date-only.ts b/src/internal/utils/date-time/is-iso-date-only.ts deleted file mode 100644 index 53e5a5089cb..00000000000 --- a/src/internal/utils/date-time/is-iso-date-only.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - -/** - * Checks if ISO date-string is date-only. - */ -export function isIsoDateOnly(dateString: string) { - return dateRegex.test(dateString); -} diff --git a/src/internal/utils/date-time/is-iso-only.ts b/src/internal/utils/date-time/is-iso-only.ts new file mode 100644 index 00000000000..ab996837b4b --- /dev/null +++ b/src/internal/utils/date-time/is-iso-only.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const dateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/; +const monthOnlyRegex = /^\d{4}-(?:0[1-9]|1[0-2])$/; + +/** + * Checks if ISO date-string is date-only. + */ +export function isIsoDateOnly(dateString: string) { + return dateOnlyRegex.test(dateString); +} + +/** + * Checks if ISO date-string is month-only. + */ +export function isIsoMonthOnly(dateString: string) { + //todo find out how to use or if to use + return monthOnlyRegex.test(dateString); +} diff --git a/src/internal/utils/handle-key.ts b/src/internal/utils/handle-key.ts index db48863cf28..09ddfe06e9c 100644 --- a/src/internal/utils/handle-key.ts +++ b/src/internal/utils/handle-key.ts @@ -9,7 +9,7 @@ export function isEventLike(event: any): event is EventLike { return isHTMLElement(event.currentTarget) || isSVGElement(event.currentTarget); } -interface EventLike { +export interface EventLike { keyCode: number; currentTarget: HTMLElement | SVGElement; } diff --git a/src/internal/utils/throttle.ts b/src/internal/utils/throttle.ts index 33635f1cae3..6bb5e4c3598 100644 --- a/src/internal/utils/throttle.ts +++ b/src/internal/utils/throttle.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -interface ThrottleOptions { +export interface ThrottleOptions { leading?: boolean; trailing?: boolean; } diff --git a/src/s3-resource-selector/s3-modal/basic-table.tsx b/src/s3-resource-selector/s3-modal/basic-table.tsx index c2b639098a2..f91024a1535 100644 --- a/src/s3-resource-selector/s3-modal/basic-table.tsx +++ b/src/s3-resource-selector/s3-modal/basic-table.tsx @@ -203,6 +203,7 @@ function InternalHeaderActions({ i18nStrings, reloadData, lastUpdated }: Inte const formattedDate = formatDateLocalized({ date: lastUpdated.toString(), isDateOnly: false, + isMonthOnly: false, }); return (