diff --git a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx index e9afb1333e..7db0e4356b 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx @@ -1,23 +1,23 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/react'; -import { addDays, roundToNearestMinutes } from 'date-fns'; import { AxiosResponse } from 'axios'; +import { addDays, roundToNearestMinutes, startOfDay } from 'date-fns'; +import { ResponseWithObject } from 'api'; +import * as apiFunctions from 'modules/Dashboard/api'; import { initialStateData } from 'redux/modules'; -import { mockedApplet, mockedPassword } from 'shared/mock'; -import { renderWithProviders } from 'shared/utils/renderWithProviders'; -import * as encryptionFunctions from 'shared/utils/encryption'; import { useFeatureFlags } from 'shared/hooks/useFeatureFlags'; -import * as apiFunctions from 'modules/Dashboard/api'; -import { mockSuccessfulHttpResponse } from 'shared/utils/axios-mocks'; -import { ResponseWithObject } from 'api'; +import { mockedApplet, mockedPassword } from 'shared/mock'; +import { getPreloadedState } from 'shared/tests/getPreloadedState'; import { ExportDataResult } from 'shared/types'; -import * as ScheduleHistoryExporterClasses from 'shared/utils/exportData/exporters/ScheduleHistoryExporter'; -import * as FlowActivityHistoryExporterClasses from 'shared/utils/exportData/exporters/FlowActivityHistoryExporter'; +import { mockSuccessfulHttpResponse } from 'shared/utils/axios-mocks'; +import * as encryptionFunctions from 'shared/utils/encryption'; import * as EHRDataExporterClasses from 'shared/utils/exportData/exporters/EHRDataExporter'; -import { getPreloadedState } from 'shared/tests/getPreloadedState'; +import * as FlowActivityHistoryExporterClasses from 'shared/utils/exportData/exporters/FlowActivityHistoryExporter'; +import * as ScheduleHistoryExporterClasses from 'shared/utils/exportData/exporters/ScheduleHistoryExporter'; +import { renderWithProviders } from 'shared/utils/renderWithProviders'; -import { ExportDataExported, ExportDateType } from './ExportDataSetting.types'; import { ExportDataSetting } from './ExportDataSetting'; +import { ExportDataExported, ExportDateType } from './ExportDataSetting.types'; const createdDate = '2023-11-14T14:43:33.369902'; @@ -160,11 +160,11 @@ describe('ExportDataSetting', () => { describe('should pass settings specified in settings popup to the export popup', () => { test.each` - exportType | expectedFromTime | description - ${ExportDateType.AllTime} | ${new Date(createdDate)} | ${'use applet create time and now for all time'} - ${ExportDateType.Last24h} | ${addDays(new Date(), -1)} | ${'use correct dates for last 24h'} - ${ExportDateType.LastWeek} | ${addDays(new Date(), -7)} | ${'use correct dates for last week'} - ${ExportDateType.LastMonth} | ${addDays(new Date(), -30)} | ${'use correct dates for last month'} + exportType | expectedFromTime | description + ${ExportDateType.AllTime} | ${startOfDay(new Date(createdDate))} | ${'use normalized applet create time for all time'} + ${ExportDateType.Last24h} | ${addDays(new Date(), -1)} | ${'use correct dates for last 24h'} + ${ExportDateType.LastWeek} | ${startOfDay(addDays(new Date(), -7))} | ${'use normalized dates for last week'} + ${ExportDateType.LastMonth} | ${startOfDay(addDays(new Date(), -30))} | ${'use normalized dates for last month'} `('$description', async ({ exportType, expectedFromTime }) => { const mockOnClose = jest.fn(); diff --git a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx index b9fa00f5ed..b6aab37cbf 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx @@ -1,4 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; +import { endOfDay, startOfDay } from 'date-fns'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { ObjectSchema } from 'yup'; @@ -39,15 +40,15 @@ export const ExportDataSetting = ({ const canExportEhrHealthData = featureFlags.enableEhrHealthData !== 'unavailable'; const minDate = useMemo(() => new Date(appletData?.createdAt ?? ''), [appletData]); - const getMaxDate = () => getNormalizedTimezoneDate(new Date().toString()); + const maxDate = useMemo(() => getNormalizedTimezoneDate(new Date().toString()), []); const defaultValues: ExportDataFormValues = useMemo( () => ({ dataExported: canExportEhrHealthData ? ExportDataExported.ResponsesAndEhrData : ExportDataExported.ResponsesOnly, dateType: ExportDateType.AllTime, - fromDate: minDate, - toDate: getMaxDate(), + fromDate: startOfDay(minDate), + toDate: endOfDay(maxDate), supplementaryFiles: SupplementaryFiles.reduce( (acc, fileType) => ({ ...acc, @@ -56,7 +57,7 @@ export const ExportDataSetting = ({ {} as Record, ), }), - [minDate, canExportEhrHealthData], + [minDate, maxDate, canExportEhrHealthData], ); const methods = useForm({ resolver: yupResolver(exportDataSettingSchema() as ObjectSchema), @@ -141,7 +142,7 @@ export const ExportDataSetting = ({ onExportSettingsClose(); }} minDate={minDate} - getMaxDate={getMaxDate} + maxDate={maxDate} contextItemName={contextItemName} supportedSupplementaryFiles={filteredSupportedSupplementaryFiles} canExportEhrHealthData={canExportEhrHealthData} diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx index f215dc8437..45025b83ae 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx @@ -15,16 +15,14 @@ import { DateFormats } from 'shared/consts'; import { ExportDataExported, ExportDataFormValues, + ExportDateType, } from 'shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types'; import { DataExportPopupProps, ExecuteAllPagesOfExportData, Modals, } from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.types'; -import { - getExportDataSuffix, - getFormattedToDate, -} from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils'; +import { getExportDataSuffix } from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils'; import { useFeatureFlags, useSetupEnterAppletPassword } from 'shared/hooks'; import { workspaces } from 'shared/state'; import { @@ -120,8 +118,17 @@ export const DataExportPopup = ({ supplementaryFiles, } = getValues?.() ?? {}; - const fromDate = formFromDate && format(formFromDate, DateFormats.shortISO); - const toDate = getFormattedToDate({ dateType, formToDate }); + let fromDate = format(formFromDate, DateFormats.shortISO); + let toDate = format(formToDate, DateFormats.shortISO); + + // Update the time for last 24 hours submissions + if (dateType === ExportDateType.Last24h) { + const currentTime = new Date(); + const oneDayAgo = new Date(currentTime); + oneDayAgo.setHours(currentTime.getHours() - 24); + fromDate = format(oneDayAgo, DateFormats.shortISO); + toDate = format(currentTime, DateFormats.shortISO); + } const includeEhr = featureFlags.enableEhrHealthData !== 'unavailable' && diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx index fee02bd2b5..bc82384d27 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { addDays, format } from 'date-fns'; import { FieldValues, FormProvider, useForm, UseFormReturn } from 'react-hook-form'; @@ -7,29 +7,33 @@ import { initialStateData } from 'redux/modules'; import { page } from 'resources'; import { DateFormats } from 'shared/consts'; import { mockedApplet, mockedAppletId } from 'shared/mock'; -import { getNormalizedTimezoneDate, SettingParam } from 'shared/utils'; +import { SettingParam } from 'shared/utils'; import { renderWithProviders } from 'shared/utils/renderWithProviders'; import { DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP } from '../../ExportDataSetting.const'; import { + ExportDataExported, ExportDataFormValues, ExportDateType, SupplementaryFiles, } from '../../ExportDataSetting.types'; import { ExportSettingsPopup } from './ExportSettingsPopup'; -const dateString = '2023-11-14T14:43:33.369902'; -const date = new Date(dateString); +const minDate = '2025-07-01T08:00:00.000000'; +const date = new Date(minDate); const preloadedState = { applet: { applet: { ...initialStateData, - data: { result: { ...mockedApplet, createdAt: dateString } }, + data: { result: { ...mockedApplet, createdAt: minDate } }, }, }, }; +const mockDateString = '2025-07-107T12:30:45'; +const mockDatePlus5Minutes = '2025-07-107T12:35:45'; + const mockOnClose = jest.fn(); const mockOnExport = jest.fn(); @@ -41,8 +45,9 @@ type FormComponentProps = { const FormComponent = ({ children, getForm }: FormComponentProps) => { const methods = useForm({ defaultValues: { + dataExported: ExportDataExported.ResponsesOnly, dateType: ExportDateType.AllTime, - fromDate: new Date(), + fromDate: date, toDate: new Date(), supplementaryFiles: SupplementaryFiles.reduce( (acc, fileType) => ({ ...acc, [fileType]: false }), @@ -61,7 +66,7 @@ const commonProps = { onClose: mockOnClose, onExport: mockOnExport, minDate: date, - getMaxDate: () => getNormalizedTimezoneDate(new Date().toString()), + maxDate: new Date(), contextItemName: mockedApplet.displayName, 'data-testid': DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP, }; @@ -156,7 +161,7 @@ describe('ExportSettingsPopup', () => { ${ExportDateType.LastWeek} | ${'last week'} ${ExportDateType.AllTime} | ${'all time'} `('$description', async ({ exportDataType }) => { - mockDate('2024-05-07T00:00:00Z'); + mockDate(mockDateString); const toDates: Set = new Set(); renderWithProviders( @@ -181,13 +186,164 @@ describe('ExportSettingsPopup', () => { await userEvent.click(downloadBtn); // 5 minutes later - mockDate('2024-05-07T00:05:00Z'); + mockDate(mockDatePlus5Minutes); await userEvent.click(downloadBtn); // The toDate should've been updated after the second click - expect(toDates.size).toEqual(2); + // Last24h's set should also contain the 5 mins afterwards, + // the rest of the options should only contain endOfDay (23:59:59) + const expectedSize = exportDataType === ExportDateType.Last24h ? 2 : 1; + expect(toDates.size).toBe(expectedSize); + }); + }); + + describe('start/end of day processing', () => { + let spy: jest.SpyInstance; + let mockDate: (dateString: string) => void; + + beforeEach(() => { + const origDateConstructor = global.Date; + spy = jest.spyOn(global, 'Date'); + mockDate = (dateString) => + spy.mockImplementation((arg) => { + if (arg !== undefined) { + return new origDateConstructor(arg); + } + + return new origDateConstructor(dateString); + }); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + test.each` + exportDataType | description + ${ExportDateType.AllTime} | ${'all time'} + ${ExportDateType.LastMonth} | ${'last month'} + ${ExportDateType.LastWeek} | ${'last week'} + ${ExportDateType.ChooseDates} | ${'choose dates'} + `('initial normalization - $description', async () => { + mockDate(mockDateString); + + const formValues: Set = new Set(); + renderWithProviders( + { + const { fromDate, toDate } = form.getValues(); + formValues.add(`${fromDate.toString()}|${toDate.toString()}`); + }} + > + + , + { preloadedState }, + ); + + expect(formValues.size).toBeGreaterThanOrEqual(2); + + // Get the latest form values after date type change + // Since we've already checked that the set has at least 2 values, + // we can be sure that pop() will not return undefined + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [fromDateStr, toDateStr] = Array.from(formValues).pop()!.split('|'); + const fromDate = new Date(fromDateStr); + const toDate = new Date(toDateStr); + + expect(fromDate.getHours()).toBe(0); + expect(fromDate.getMinutes()).toBe(0); + expect(fromDate.getSeconds()).toBe(0); + expect(toDate.getHours()).toBe(23); + expect(toDate.getMinutes()).toBe(59); + expect(toDate.getSeconds()).toBe(59); + }); + }); + + it('should normalize choose dates after interaction', async () => { + const formValues: Set = new Set(); + let form: any; + renderWithProviders( + { + form = formInstance; + const { fromDate, toDate } = form.getValues(); + formValues.add( + `${fromDate.toISOString().split('Z')[0]}|${toDate.toISOString().split('Z')[0]}`, + ); + }} + > + + , + { preloadedState }, + ); + + // Switch to ChooseDates + const dateTypeInput = screen + .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-dateType`) + .querySelector('input'); + dateTypeInput && + fireEvent.change(dateTypeInput, { target: { value: ExportDateType.ChooseDates } }); + + // Wait for the form to update after date type change + await waitFor(() => { + expect(dateTypeInput?.value).toBe(ExportDateType.ChooseDates); }); + + // Clear previous values to focus on interaction changes + formValues.clear(); + + const testFromDate = new Date('2025-07-15T14:30:00'); + const testToDate = new Date('2025-07-20T16:45:00'); + + // Manually set the dates (simulating what happens during date selection) + act(() => { + form.setValue('fromDate', testFromDate); + form.setValue('toDate', testToDate); + }); + + // Trigger the normalization by simulating a popover close event + const fromDateInput = screen + .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-from-date`) + .querySelector('input'); + const toDateInput = screen + .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-to-date`) + .querySelector('input'); + + if (fromDateInput && toDateInput) { + // Open and close the fromDate picker to trigger normalization + await userEvent.click(fromDateInput); + + // Wait for popover to open + await waitFor(() => { + expect( + screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-from-date-popover`), + ).toBeInTheDocument(); + }); + + // Close the popover by pressing Escape - this should trigger onCloseCallback + await userEvent.keyboard('{Escape}'); + + // Open and close the toDate picker to trigger normalization + await userEvent.click(toDateInput); + + // Wait for popover to open + await waitFor(() => { + expect( + screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-to-date-popover`), + ).toBeInTheDocument(); + }); + + // Close the popover by pressing Escape - this should trigger onCloseCallback + await userEvent.keyboard('{Escape}'); + + // Wait for normalization to complete + await waitFor(() => { + const values = form.getValues(); + expect(values.fromDate.getHours()).toBe(0); + expect(values.toDate.getHours()).toBe(23); + }); + } }); describe("should appear export data popup for 'choose dates' date range", () => { @@ -214,7 +370,7 @@ describe('ExportSettingsPopup', () => { const toDate = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-to-date`); const toDateInput = toDate.querySelector('input'); expect(fromDate).toBeVisible(); - expect(fromDateInput?.value).toBe('14 Nov 2023'); + expect(fromDateInput?.value).toBe('01 Jul 2025'); expect(toDate).toBeVisible(); expect(toDateInput?.value).toBe(format(new Date(), DateFormats.DayMonthYear)); diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx index f5649363ae..12b3ec3e95 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx @@ -1,6 +1,6 @@ import { Button } from '@mui/material'; import { addDays, endOfDay, startOfDay } from 'date-fns'; -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -18,7 +18,6 @@ import { StyledTitleBoldMedium, theme, } from 'shared/styles'; -import { SelectEvent } from 'shared/types'; import { ExportDataFormValues, @@ -33,7 +32,7 @@ export const ExportSettingsPopup = ({ onClose, onExport, minDate, - getMaxDate, + maxDate, contextItemName, supportedSupplementaryFiles, canExportEhrHealthData, @@ -49,7 +48,7 @@ export const ExportSettingsPopup = ({ const hasCustomDate = dateType === ExportDateType.ChooseDates; const commonProps = { - maxDate: getMaxDate(), + maxDate, control, inputSx: { '& .MuiInputLabel-outlined': { @@ -58,45 +57,56 @@ export const ExportSettingsPopup = ({ }, }; - const onFromDateSubmit = (date: DateType) => { - if (!date) return; - setValue('fromDate', startOfDay(date)); - }; - const onToDateSubmit = (date: DateType) => { - if (!date) return; - setValue('toDate', endOfDay(date)); - }; - const onDatePickerClose = () => { + const normalizeFromDate = useCallback( + (date: DateType | undefined) => { + if (!date) return; + setValue('fromDate', startOfDay(date)); + }, + [setValue], + ); + + const normalizeToDate = useCallback( + (date: DateType | undefined) => { + if (!date) return; + setValue('toDate', endOfDay(date)); + }, + [setValue], + ); + + const onFromDatePickerClose = () => { + let newToDate = toDate; if (toDate < fromDate) { - setValue('toDate', addDays(fromDate, 1)); + const increasedFromDate = addDays(fromDate, 1); + + newToDate = increasedFromDate <= maxDate ? increasedFromDate : maxDate; } + normalizeToDate(newToDate); }; - const onDateTypeChange = (e: SelectEvent) => { - const dateType = e.target.value as ExportDateType; - const maxDate = getMaxDate(); + + useEffect(() => { switch (dateType) { case ExportDateType.AllTime: - setValue('fromDate', minDate); - setValue('toDate', maxDate); + normalizeFromDate(minDate); + normalizeToDate(maxDate); break; case ExportDateType.Last24h: setValue('fromDate', addDays(maxDate, -1)); setValue('toDate', maxDate); break; case ExportDateType.LastWeek: - setValue('fromDate', addDays(maxDate, -7)); - setValue('toDate', maxDate); + normalizeFromDate(addDays(maxDate, -7)); + normalizeToDate(maxDate); break; case ExportDateType.LastMonth: - setValue('fromDate', addDays(maxDate, -30)); - setValue('toDate', maxDate); + normalizeFromDate(addDays(maxDate, -30)); + normalizeToDate(maxDate); break; case ExportDateType.ChooseDates: - setValue('fromDate', minDate); - setValue('toDate', maxDate); + normalizeFromDate(minDate); + normalizeToDate(maxDate); break; } - }; + }, [dateType, minDate, maxDate, normalizeFromDate, normalizeToDate, setValue]); const filteredSupplementaryFiles = useMemo( () => @@ -140,7 +150,6 @@ export const ExportSettingsPopup = ({ options={getDateTypeOptions()} label={t('dateRange')} data-testid={`${dataTestId}-dateType`} - customChange={onDateTypeChange} fullWidth /> @@ -149,8 +158,10 @@ export const ExportSettingsPopup = ({ { + normalizeFromDate(date); + onFromDatePickerClose(); + }} label={t('startDate')} minDate={minDate} data-testid={`${dataTestId}-from-date`} @@ -162,7 +173,7 @@ export const ExportSettingsPopup = ({