From df18a986b0dc35f910a7a5531785bb70b6ff98a9 Mon Sep 17 00:00:00 2001 From: daniel-zamora Date: Tue, 28 Jan 2025 11:54:48 -0500 Subject: [PATCH] EDSC-4125: Recurring temporal slider doesn't work (#1841) * EDSC-4125 GranuleFilter DatePicker improvements * EDSC-4125 prevents InputRange request spam * EDSC-4125 some fixes to datepicker * EDSC-4125 some fixes to recurring granule filter * EDSC-4125 adds test cases for updated date picker logic * EDSC-4125 some test fixes * EDSC-4125 test fixes * EDSC-4125 cleanup * EDSC-4125 fixes collection temporal pickers * EDSC-4076 slight refactor * EDSC-4076 slight refactor * EDSC-4125 adds temporal dropdown test for recurring * EDSC-4125 test fixes * EDSC-4125 pr comments * EDSC-4125 adds mockdate to datepicker test * EDSC-4125 handles unset date cases * EDSC-4125 test fixes * EDSC-4125 utc time zone test fixes * EDSC-4125 test fix test * EDSC-4125 changes display value in datepicker, updates tests * EDSC-4125 granule filter fix * EDSC-4125 granule filters form fixes * EDSC-4125 pr comments --- .../js/components/Datepicker/Datepicker.jsx | 204 +++++++++--- .../Datepicker/__tests__/Datepicker.test.jsx | 144 ++++++++- .../GranuleFilters/GranuleFiltersForm.jsx | 104 ++++-- .../__tests__/GranuleFiltersForm.test.jsx | 140 +++++++- .../TemporalSelectionDropdown.jsx | 101 +++++- .../TemporalSelectionDropdownMenu.jsx | 20 +- .../TemporalSelectionDropdown.test.jsx | 301 ++++++++++++++++++ .../TemporalSelection/TemporalSelection.jsx | 25 +- .../__tests__/TemporalSelection.test.jsx | 72 ++++- 9 files changed, 1006 insertions(+), 105 deletions(-) diff --git a/static/src/js/components/Datepicker/Datepicker.jsx b/static/src/js/components/Datepicker/Datepicker.jsx index 6e968fef26..586d946c38 100644 --- a/static/src/js/components/Datepicker/Datepicker.jsx +++ b/static/src/js/components/Datepicker/Datepicker.jsx @@ -1,5 +1,4 @@ import React, { PureComponent } from 'react' -import ReactDOM from 'react-dom' import PropTypes from 'prop-types' import Datetime from 'react-datetime' @@ -23,6 +22,11 @@ import './Datepicker.scss' * @param {String} props.viewMode - The default view mode for the picker */ class Datepicker extends PureComponent { + constructor(props) { + super(props) + this.containerRef = React.createRef() + } + componentDidMount() { const { onTodayClick, @@ -30,7 +34,8 @@ class Datepicker extends PureComponent { } = this.props // Add a custom set of "Today" and "Clear" buttons and insert them into the picker - const container = ReactDOM.findDOMNode(this).querySelector('.rdtPicker') // eslint-disable-line + const container = this.containerRef.current?.querySelector('.rdtPicker') + if (!container) return // Container to hold custom buttons const buttonContainer = document.createElement('div') @@ -55,23 +60,24 @@ class Datepicker extends PureComponent { // Adds the new button container to the DOM container.appendChild(buttonContainer) + + // Set up navigation button click handlers + this.setupNavigationHandlers(container) } componentDidUpdate(prevProps) { - const { - viewMode: previousViewMode - } = prevProps - const { viewMode, picker } = this.props // If the viewMode has changed, navigate to the new viewMode - if (previousViewMode !== viewMode) picker.current.navigate(viewMode) + if (prevProps.viewMode !== viewMode) { + picker.current.navigate(viewMode) + } } - onInputChange(event) { + onInputChange = (event) => { const caret = event.target.selectionStart const element = event.target @@ -82,6 +88,97 @@ class Datepicker extends PureComponent { }) } + setupNavigationHandlers = (container) => { + container.addEventListener('click', (event) => { + // Handle month selection + if (event.target.classList.contains('rdtMonth')) { + requestAnimationFrame(() => this.updateNavigationArrows()) + } + + // Handle navigation arrows + if (event.target.classList.contains('rdtPrev') + || event.target.parentElement.classList.contains('rdtPrev') + || event.target.classList.contains('rdtNext') + || event.target.parentElement.classList.contains('rdtNext')) { + requestAnimationFrame(() => this.updateNavigationArrows()) + } + + // Handle going back to months view + if (event.target.classList.contains('rdtSwitch')) { + // Reset arrow visibility when going back to months view + const prevButton = container.querySelector('.rdtPrev') + const nextButton = container.querySelector('.rdtNext') + if (prevButton) { + prevButton.innerHTML = '' + prevButton.style.pointerEvents = '' + prevButton.style.visibility = 'visible' + } + + if (nextButton) { + nextButton.innerHTML = '' + nextButton.style.pointerEvents = '' + nextButton.style.visibility = 'visible' + } + } + }) + } + + setupCalendar = () => { + const container = this.containerRef.current?.querySelector('.rdtPicker') + if (container) { + this.setupNavigationHandlers(container) + this.updateNavigationArrows() + } + } + + updateNavigationArrows = () => { + const { viewMode } = this.props + if (viewMode !== 'months') return + + const container = this.containerRef.current?.querySelector('.rdtPicker') + if (!container) return + + const isDayView = container.querySelector('.rdtDays') !== null + if (!isDayView) return + + const prevButton = container.querySelector('.rdtPrev') + const nextButton = container.querySelector('.rdtNext') + const monthDisplay = container.querySelector('.rdtSwitch') + + if (!monthDisplay) return + + // Modify month display to remove year + const [monthStr] = monthDisplay.textContent.split(' ') + monthDisplay.textContent = monthStr + + const currentMonth = new Date(`${monthStr} 1, 2000`).getMonth() + + // Check if navigation would cross year boundary + if (prevButton) { + if (currentMonth === 0) { + prevButton.innerHTML = '' + prevButton.style.pointerEvents = 'none' + prevButton.style.visibility = 'hidden' + } else { + prevButton.innerHTML = '' + prevButton.style.pointerEvents = '' + prevButton.style.visibility = 'visible' + } + } + + if (nextButton) { + if (currentMonth === 11) { + nextButton.innerHTML = '' + nextButton.style.pointerEvents = 'none' + nextButton.style.visibility = 'hidden' + } else { + nextButton.innerHTML = '' + nextButton.style.pointerEvents = '' + nextButton.style.visibility = 'visible' + } + } + } + render() { const { filterType, @@ -92,16 +189,15 @@ class Datepicker extends PureComponent { size, value, onInputBlur, - onInputFocus + onInputFocus, + format, + id, + viewMode } = this.props - const { format, id, viewMode } = this.props - const conditionalInputProps = {} // React-datetime does not clear out the input field when a empty string is received. When // the value is an empty string, the value is manually set on the input via `inputProps`. - if (!value) { - conditionalInputProps.value = '' - } + const conditionalInputProps = !value ? { value: '' } : {} const onKeyDown = (event) => { // If the user presses `Enter`, the field should behave the same as bluring the input @@ -109,43 +205,51 @@ class Datepicker extends PureComponent { } return ( - { - this.onInputChange(event) - // eslint-disable-next-line no-underscore-dangle - picker.current._closeCalendar() - if (filterType === 'collection') { - onChange(event.target.value, false, 'Typed') - } - }, - onBlur: onInputBlur, - onFocus: onInputFocus, - onKeyDown, - ...conditionalInputProps +
+ { + this.onInputChange(event) + // eslint-disable-next-line no-underscore-dangle + picker.current._closeCalendar() + if (filterType === 'collection') { + onChange(event.target.value, false, 'Typed') + } + }, + onBlur: onInputBlur, + onFocus: onInputFocus, + + onKeyDown, + ...conditionalInputProps + } } - } - isValidDate={isValidDate} - onChange={onChange} - ref={picker} - strictParsing - timeFormat={false} - utc - value={value} - viewMode={viewMode} - /> + isValidDate={isValidDate} + onChange={onChange} + onOpen={ + () => { + requestAnimationFrame(() => this.setupCalendar()) + } + } + ref={picker} + strictParsing + timeFormat={false} + utc + value={value} + viewMode={viewMode} + /> +
) } } diff --git a/static/src/js/components/Datepicker/__tests__/Datepicker.test.jsx b/static/src/js/components/Datepicker/__tests__/Datepicker.test.jsx index cc0bfdd16d..50b697730b 100644 --- a/static/src/js/components/Datepicker/__tests__/Datepicker.test.jsx +++ b/static/src/js/components/Datepicker/__tests__/Datepicker.test.jsx @@ -3,8 +3,10 @@ import React from 'react' import { act, render, - screen + screen, + waitFor } from '@testing-library/react' +import MockDate from 'mockdate' import userEvent from '@testing-library/user-event' @@ -50,6 +52,14 @@ const setup = (overrideProps) => { } describe('Datepicker component', () => { + beforeEach(() => { + MockDate.set('2024-01-01T01:00:00.000Z') + }) + + afterEach(() => { + MockDate.reset() + }) + describe('on render', () => { test('creates the custom buttons', () => { setup() @@ -112,7 +122,7 @@ describe('Datepicker component', () => { await user.type(datePickerInput, '1') }) - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1) + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2) expect(requestAnimationFrameSpy).toHaveBeenCalledWith(expect.any(Function)) expect(closeCalendarSpy).toHaveBeenCalledTimes(1) @@ -216,4 +226,134 @@ describe('Datepicker component', () => { expect(navigateSpy).toHaveBeenCalledWith('month') }) }) + + describe('when handling navigation arrows and month display', () => { + describe('in years viewMode', () => { + test('renders initial year range view correctly', async () => { + const { user } = setup({ viewMode: 'years' }) + + // Open the calendar + const input = screen.getByRole('textbox', { name: 'Date Time' }) + await user.click(input) + + // Verify year range header and navigation + const yearHeader = screen.getByRole('columnheader', { name: '2020-2029' }) + expect(yearHeader).toBeInTheDocument() + + const prevNav = screen.getByRole('columnheader', { name: '‹' }) + const nextNav = screen.getByRole('columnheader', { name: '›' }) + expect(prevNav).toBeVisible() + expect(nextNav).toBeVisible() + }) + + test('allows normal navigation between year ranges', async () => { + const { user } = setup({ viewMode: 'years' }) + + // Open the calendar + const input = screen.getByRole('textbox', { name: 'Date Time' }) + await user.click(input) + + const prevNav = screen.getByRole('columnheader', { name: '‹' }) + await user.click(prevNav) + + const prevDecadeHeader = screen.getByRole('columnheader', { name: '2010-2019' }) + expect(prevDecadeHeader).toBeInTheDocument() + + const nextNav = screen.getByRole('columnheader', { name: '›' }) + await user.click(nextNav) + + const currentDecadeHeader = screen.getByRole('columnheader', { name: '2020-2029' }) + expect(currentDecadeHeader).toBeInTheDocument() + }) + }) + + describe('in months viewMode', () => { + test('displays month selection without year and handles navigation visibility', async () => { + const { user } = setup({ + viewMode: 'months', + value: '06-01 00:00:00', + format: 'MM-DD HH:mm:ss', + shouldValidate: false, + isValidDate: jest.fn().mockReturnValue(true) + }) + + // Open the calendar + const input = screen.getByRole('textbox', { name: 'Date Time' }) + await user.click(input) + + // Initial month selection view should have year and navigation + const [prevNav, monthSwitch, nextNav] = screen.getAllByRole('columnheader') + expect(monthSwitch).toHaveTextContent('2024') + expect(prevNav).toBeVisible() + expect(nextNav).toBeVisible() + + // Verify June is selected + const juneCell = screen.getByRole('cell', { name: 'Jun' }) + expect(juneCell).toHaveClass('rdtMonth', 'rdtActive') + + // Click June to enter days view + await user.click(juneCell) + + // Get fresh references after entering days view + const [currentPrevNav, currentMonthSwitch, currentNextNav] = screen + .getAllByRole('columnheader') + .filter((header) => header.className.includes('rdtPrev') + || header.className.includes('rdtSwitch') + || header.className.includes('rdtNext')) + + // June state in days view + await waitFor(() => { + expect(currentMonthSwitch).toHaveTextContent('June') + }) + + expect(currentMonthSwitch).not.toHaveTextContent('2024') + expect(currentPrevNav).toBeVisible() + expect(currentNextNav).toBeVisible() + + // Navigate to January using prev nav + await user.click(currentPrevNav) // May + await user.click(currentPrevNav) // April + await user.click(currentPrevNav) // March + await user.click(currentPrevNav) // February + await user.click(currentPrevNav) // January + + // January state in days view + await waitFor(() => { + expect(currentMonthSwitch).toHaveTextContent('January') + }) + + await waitFor(() => { + expect(currentMonthSwitch).not.toHaveTextContent('2024') + }) + + expect(currentPrevNav).not.toBeVisible() // Hidden in January + expect(currentNextNav).toBeVisible() + + // Navigate from February to December + await user.click(currentNextNav) // February + await user.click(currentNextNav) // March + await user.click(currentNextNav) // April + await user.click(currentNextNav) // May + await user.click(currentNextNav) // June + await user.click(currentNextNav) // July + await user.click(currentNextNav) // August + await user.click(currentNextNav) // September + await user.click(currentNextNav) // October + await user.click(currentNextNav) // November + await user.click(currentNextNav) // December + + // December state in days view + await waitFor(() => { + expect(currentMonthSwitch).toHaveTextContent('December') + }) + + await waitFor(() => { + expect(currentMonthSwitch).not.toHaveTextContent('2024') + }) + + expect(currentPrevNav).toBeVisible() + expect(currentNextNav).not.toBeVisible() // Hidden in December + }) + }) + }) }) diff --git a/static/src/js/components/GranuleFilters/GranuleFiltersForm.jsx b/static/src/js/components/GranuleFilters/GranuleFiltersForm.jsx index 0ffe0433fa..6b8d27369a 100644 --- a/static/src/js/components/GranuleFilters/GranuleFiltersForm.jsx +++ b/static/src/js/components/GranuleFilters/GranuleFiltersForm.jsx @@ -15,6 +15,7 @@ import { findGridByName } from '../../util/grid' import { getTemporalDateFormat } from '../../../../../sharedUtils/edscDate' import { getValueForTag } from '../../../../../sharedUtils/tags' import { pluralize } from '../../util/pluralize' +import { getApplicationConfig } from '../../../../../sharedUtils/config' import SidebarFiltersItem from '../Sidebar/SidebarFiltersItem' import SidebarFiltersList from '../Sidebar/SidebarFiltersList' @@ -26,6 +27,7 @@ import './GranuleFiltersForm.scss' /** * Renders GranuleFiltersForm. + * @param {Object} props - The props passed into the component. * @param {Object} props.collectionMetadata - The focused collections metadata. * @param {Object} props.errors - Form errors provided by Formik. * @param {Object} props.excludedGranuleIds - The list of excluded granules. @@ -34,12 +36,8 @@ import './GranuleFiltersForm.scss' * @param {Function} props.handleSubmit - Callback function passed from the container. * @param {Function} props.setFieldTouched - Callback function provided by Formik. * @param {Function} props.setFieldValue - Callback function provided by Formik. - * @param {Object} props - The props passed into the component. - * @param {Object} props.collectionMetadata - The focused collection metadata. * @param {Object} props.onMetricsGranuleFilter - Callback function passed from actions. * @param {Object} props.onUndoExcludeGranule - Callback function passed from actions. - * @param {Function} props.setFieldTouched - Callback function provided by Formik. - * @param {Function} props.setFieldValue - Callback function provided by Formik. * @param {Object} props.touched - Form state provided by Formik. * @param {Object} props.values - Form values provided by Formik. */ @@ -78,6 +76,15 @@ export const GranuleFiltersForm = (props) => { // For recurring dates we don't show the year, it's displayed on the slider const temporalDateFormat = getTemporalDateFormat(isRecurring) + const { + minimumTemporalDateString, + temporalDateFormatFull + } = getApplicationConfig() + const minDate = moment( + minimumTemporalDateString, + temporalDateFormatFull + ) + const { min: cloudCoverMin = '', max: cloudCoverMax = '' @@ -439,7 +446,15 @@ export const GranuleFiltersForm = (props) => { size="sm" format={temporalDateFormat} temporal={temporal} + displayStartDate={temporal.startDate} + displayEndDate={temporal.endDate} validate={false} + onSliderChange={ + (value) => { + setFieldValue('temporal.startDate', moment(temporal.startDate).year(value.min).toISOString()) + setFieldValue('temporal.endDate', moment(temporal.endDate).year(value.max).toISOString()) + } + } onRecurringToggle={ (event) => { const isChecked = event.target.checked @@ -447,17 +462,23 @@ export const GranuleFiltersForm = (props) => { setFieldValue('temporal.isRecurring', isChecked) setFieldTouched('temporal.isRecurring', isChecked) - // If recurring is checked and values exist, set the recurringDay values - if (isChecked) { - const newStartDate = moment(temporal.startDate || undefined).utc() + if (isChecked && temporal) { if (temporal.startDate) { - setFieldValue('temporal.recurringDayStart', newStartDate.dayOfYear()) - } + const startDate = moment(temporal.startDate).utc() + + if (temporal.endDate) { + const endDate = moment(temporal.endDate).utc() - const newEndDate = moment(temporal.endDate || undefined).utc() - if (temporal.endDate) { - // Use the start year to calculate the end day of year. This avoids leap years potentially causing day mismatches - setFieldValue('temporal.recurringDayEnd', newEndDate.year(newStartDate.year()).dayOfYear()) + if (startDate.year() === endDate.year()) { + // Preserve original month/day while setting to minimum year + const newStartDate = moment(startDate).year(minDate.year()) + setFieldValue('temporal.startDate', newStartDate.toISOString()) + } + + setFieldValue('temporal.recurringDayStart', startDate.dayOfYear()) + // Use the start year to calculate the end day of year. This avoids leap years potentially causing day mismatches + setFieldValue('temporal.recurringDayEnd', endDate.year(startDate.year()).dayOfYear()) + } } } @@ -502,15 +523,33 @@ export const GranuleFiltersForm = (props) => { } onSubmitStart={ (startDate, shouldSubmit) => { + const { temporal: newTemporal } = values + + // If the recurring toggle is toggled on, the format of the date drops the year, when + // converted into a moment object, this will erroneously set the year to the current year. + // To avoid this, we check if the temporal is recurring and set the year to the existing year in + // state. + if (newTemporal.isRecurring) { + const existingStartDate = moment(newTemporal.startDate) + if (existingStartDate.isValid()) { + const existingStartDateYear = existingStartDate.year() + startDate.year(existingStartDateYear) + } + } + const { input } = startDate.creationData() const value = startDate.isValid() ? startDate.toISOString() : input - setFieldValue('temporal.startDate', value) setFieldTouched('temporal.startDate') - const { temporal: newTemporal } = values - if (newTemporal.isRecurring) { - setFieldValue('temporal.recurringDayStart', startDate.dayOfYear()) + if (newTemporal.isRecurring && newTemporal.endDate && startDate.isValid()) { + const endDate = moment(newTemporal.endDate).utc() + + if (startDate.year() === endDate.year()) { + // Preserve original month/day while setting to minimum year + startDate.year(minDate.year()) + setFieldValue('temporal.startDate', startDate.toISOString()) + } } // Only call handleSubmit if `onSubmitStart` was called @@ -527,15 +566,32 @@ export const GranuleFiltersForm = (props) => { } onSubmitEnd={ (endDate, shouldSubmit) => { + const { temporal: newTemporal } = values + + // Like with start date, if the recurring toggle is toggled on, the format of the date drops the year. + // To avoid this, we check if the temporal is recurring and set the year to the existing year in state. + if (newTemporal.isRecurring) { + const existingEndDate = moment(newTemporal.endDate) + if (existingEndDate.isValid()) { + const existingEndDateYear = existingEndDate.year() + endDate.year(existingEndDateYear) + } + } + const { input } = endDate.creationData() const value = endDate.isValid() ? endDate.toISOString() : input setFieldValue('temporal.endDate', value) setFieldTouched('temporal.endDate') - const { temporal: newTemporal } = values - if (newTemporal.isRecurring) { - setFieldValue('temporal.recurringDayEnd', endDate.dayOfYear()) + if (newTemporal.isRecurring && newTemporal.startDate && endDate.isValid()) { + const startDate = moment(newTemporal.startDate).utc() + + if (startDate.year() === endDate.year()) { + // Preserve original month/day while setting to minimum year + startDate.year(minDate.year()) + setFieldValue('temporal.startDate', startDate.toISOString()) + } } if (shouldSubmit && (endDate.isValid() || !input)) { @@ -851,6 +907,14 @@ export const GranuleFiltersForm = (props) => { size="sm" format={temporalDateFormat} temporal={equatorCrossingDate} + displayStartDate={equatorCrossingDate.startDate} + displayEndDate={equatorCrossingDate.endDate} + onSliderChange={ + (value) => { + setFieldValue('temporal.startDate', moment(temporal.startDate).year(value.min).toISOString()) + setFieldValue('temporal.endDate', moment(temporal.endDate).year(value.max).toISOString()) + } + } validate={false} onSubmitStart={ (startDate, shouldSubmit) => { diff --git a/static/src/js/components/GranuleFilters/__tests__/GranuleFiltersForm.test.jsx b/static/src/js/components/GranuleFilters/__tests__/GranuleFiltersForm.test.jsx index daaf6c325f..c28e0cbb3d 100644 --- a/static/src/js/components/GranuleFilters/__tests__/GranuleFiltersForm.test.jsx +++ b/static/src/js/components/GranuleFilters/__tests__/GranuleFiltersForm.test.jsx @@ -41,7 +41,13 @@ const setup = (overrideProps) => { setFieldValue, setFieldTouched, touched: {}, - values: {}, + values: { + temporal: { + isRecurring: false, + startDate: '', + endDate: '' + } + }, ...overrideProps } @@ -1545,4 +1551,136 @@ describe('GranuleFiltersForm component', () => { expect(screen.getByText(gridCoordinatesMessage)).toBeVisible() }) }) + + describe('Recurring date toggle behavior', () => { + test('when toggling recurring with same year dates, adjusts start date to minimum year', async () => { + const { + setFieldValue, setFieldTouched, handleSubmit, onMetricsGranuleFilter, user + } = setup({ + values: { + temporal: { + startDate: '2024-03-01T00:00:00.000Z', + endDate: '2024-12-31T23:59:59.999Z', + isRecurring: false + } + } + }) + + const recurringCheckbox = screen.getByRole('checkbox', { name: 'Recurring?' }) + await user.click(recurringCheckbox) + + // Verify isRecurring was set + expect(setFieldValue).toHaveBeenCalledWith('temporal.isRecurring', true) + expect(setFieldTouched).toHaveBeenCalledWith('temporal.isRecurring', true) + + // Verify start date was adjusted to minimum year while preserving month/day + expect(setFieldValue).toHaveBeenCalledWith('temporal.startDate', '1960-03-01T00:00:00.000Z') + + // Verify recurringDay values were set correctly + expect(setFieldValue).toHaveBeenCalledWith('temporal.recurringDayStart', 61) // March 1st + expect(setFieldValue).toHaveBeenCalledWith('temporal.recurringDayEnd', 366) // December 31st + + // Verify form was submitted + expect(handleSubmit).toHaveBeenCalled() + + // Verify metrics were recorded + expect(onMetricsGranuleFilter).toHaveBeenCalledWith({ + type: 'Set Recurring', + value: true + }) + }) + + test('when toggling recurring with different year dates, preserves original start date year', async () => { + const { setFieldValue, user } = setup({ + values: { + temporal: { + startDate: '2023-03-01T00:00:00.000Z', + endDate: '2024-12-31T23:59:59.999Z', + isRecurring: false + } + } + }) + + const recurringCheckbox = screen.getByRole('checkbox', { name: 'Recurring?' }) + await user.click(recurringCheckbox) + + // Verify start date was NOT adjusted since years are different + expect(setFieldValue).not.toHaveBeenCalledWith( + 'temporal.startDate', + expect.stringContaining('1960') + ) + }) + }) + + describe('Start date submission behavior', () => { + test('when submitting start date in same year as end date with recurring enabled, adjusts to minimum year', async () => { + MockDate.set('2024-03-15') + + const { + setFieldValue, handleSubmit, onMetricsGranuleFilter, user + } = setup({ + values: { + temporal: { + isRecurring: true, + startDate: '', + endDate: '2024-12-31T23:59:59.999Z' + } + } + }) + + // Click "Today" button for start date + const todayButton = screen.getAllByRole('button', { name: 'Today' })[0] + await user.click(todayButton) + + // Verify start date was adjusted to minimum year while preserving month/day + expect(setFieldValue).toHaveBeenCalledWith( + 'temporal.startDate', + '1960-03-15T00:00:00.000Z' + ) + + expect(handleSubmit).toHaveBeenCalled() + expect(onMetricsGranuleFilter).toHaveBeenCalledWith({ + type: 'Set Start Date', + value: '2024-03-15T00:00:00.000Z' + }) + + MockDate.reset() + }) + }) + + describe('End date submission behavior', () => { + test.only('when submitting end date in same year as start date with recurring enabled, adjusts start date to minimum year', async () => { + MockDate.set('2024-12-15') + + const { + setFieldValue, handleSubmit, onMetricsGranuleFilter, user + } = setup({ + values: { + temporal: { + isRecurring: true, + startDate: '2024-03-15T00:00:00.000Z', + endDate: '' + } + } + }) + + // Click "Today" button for end date + const todayButton = screen.getAllByRole('button', { name: 'Today' })[1] + await user.click(todayButton) + + // Verify start date was adjusted to minimum year while preserving month/day + expect(setFieldValue).toHaveBeenCalledWith( + 'temporal.startDate', + '1960-03-15T00:00:00.000Z' + ) + + expect(handleSubmit).toHaveBeenCalled() + expect(onMetricsGranuleFilter).toHaveBeenCalledWith({ + type: 'Set End Date', + value: '2024-12-15T23:59:59.000Z' + }) + + MockDate.reset() + }) + }) }) diff --git a/static/src/js/components/TemporalDisplay/TemporalSelectionDropdown.jsx b/static/src/js/components/TemporalDisplay/TemporalSelectionDropdown.jsx index b5b5dd00e7..cc368bd58c 100644 --- a/static/src/js/components/TemporalDisplay/TemporalSelectionDropdown.jsx +++ b/static/src/js/components/TemporalDisplay/TemporalSelectionDropdown.jsx @@ -42,9 +42,18 @@ const TemporalSelectionDropdown = ({ recurringDayEnd, isRecurring }) + const [datesSelected, setDatesSelected] = useState({ + start: false, + end: false + }) useEffect(() => { setTemporal(temporalSearch) + + setDatesSelected({ + start: !!temporalSearch.startDate, + end: !!temporalSearch.endDate + }) }, [temporalSearch]) /** @@ -98,6 +107,11 @@ const TemporalSelectionDropdown = ({ * Clears the current temporal values internally and within the Redux store */ const onClearClick = () => { + setDatesSelected({ + start: false, + end: false + }) + setTemporal({ startDate: '', endDate: '', @@ -134,10 +148,67 @@ const TemporalSelectionDropdown = ({ }) } - setTemporal({ - ...temporal, - isRecurring: isChecked - }) + try { + if (isChecked) { + const { + startDate: existingStartDate, + endDate: existingEndDate + } = temporal + const { minimumTemporalDateString, temporalDateFormatFull } = getApplicationConfig() + const minDate = moment(minimumTemporalDateString, temporalDateFormatFull) + + // When both dates exist and are in the same year, adjust start to min year + if (existingStartDate && existingEndDate) { + const startYear = moment(existingStartDate).utc().year() + const endYear = moment(existingEndDate).utc().year() + if (startYear === endYear) { + setTemporal({ + ...temporal, + isRecurring: isChecked, + startDate: moment(existingStartDate).utc().year(minDate.year()).toISOString(), + endDate: existingEndDate + }) + + return + } + } + + // When only start date exists and is in current year, use full range + if (existingStartDate && !existingEndDate) { + const startYear = moment.utc(existingStartDate).year() + const currentYear = moment().utc().year() + if (startYear === currentYear) { + setTemporal({ + ...temporal, + isRecurring: isChecked, + startDate: moment(existingStartDate).utc().year(minDate.year()).toISOString(), + endDate: moment().utc().toISOString() + }) + + return + } + } + + setTemporal({ + ...temporal, + isRecurring: isChecked, + startDate: existingStartDate || minDate.utc().startOf('year').toISOString(), + endDate: existingEndDate || moment.utc().toISOString() + }) + + return + } + + setTemporal({ + ...temporal, + isRecurring: isChecked + }) + } catch (error) { + setTemporal({ + ...temporal, + isRecurring: isChecked + }) + } } /** @@ -194,6 +265,11 @@ const TemporalSelectionDropdown = ({ startDate: existingStartDate } = temporal + setDatesSelected((prev) => ({ + ...prev, + start: true + })) + if (shouldCallMetrics && onMetricsTemporalFilter) { onMetricsTemporalFilter({ type: `Set Start Date - ${metricType}`, @@ -229,6 +305,11 @@ const TemporalSelectionDropdown = ({ isRecurring: existingIsRecurring } = temporal + setDatesSelected((prev) => ({ + ...prev, + end: true + })) + if (shouldCallMetrics && onMetricsTemporalFilter) { onMetricsTemporalFilter({ type: `Set End Date - ${metricType}`, @@ -266,10 +347,22 @@ const TemporalSelectionDropdown = ({ onClearClick={onClearClick} onInvalid={onInvalid} onRecurringToggle={onRecurringToggle} + onSliderChange={ + (value) => { + const { min, max } = value + setTemporal({ + ...temporal, + startDate: moment(temporal.startDate).year(min).toISOString(), + endDate: moment(temporal.endDate).year(max).toISOString() + }) + } + } onValid={onValid} setEndDate={setEndDate} setStartDate={setStartDate} temporal={temporal} + displayStartDate={datesSelected.start ? temporal.startDate : ''} + displayEndDate={datesSelected.end ? temporal.endDate : ''} /> ) } diff --git a/static/src/js/components/TemporalDisplay/TemporalSelectionDropdownMenu.jsx b/static/src/js/components/TemporalDisplay/TemporalSelectionDropdownMenu.jsx index 802616c96f..3ba8a47050 100644 --- a/static/src/js/components/TemporalDisplay/TemporalSelectionDropdownMenu.jsx +++ b/static/src/js/components/TemporalDisplay/TemporalSelectionDropdownMenu.jsx @@ -21,10 +21,13 @@ const TemporalSelectionDropdownMenu = ({ onChangeRecurring, onInvalid, onRecurringToggle, + onSliderChange, onValid, setEndDate, setStartDate, - temporal + temporal, + displayStartDate, + displayEndDate }) => { const classes = { btnApply: classNames( @@ -64,8 +67,11 @@ const TemporalSelectionDropdownMenu = ({ onChangeQuery={onChangeQuery} onSubmitStart={setStartDate} onSubmitEnd={setEndDate} + onSliderChange={onSliderChange} onValid={onValid} onInvalid={onInvalid} + displayStartDate={displayStartDate} + displayEndDate={displayEndDate} />