88 */
99
1010import React from 'react' ;
11- import { fireEvent , screen } from '@testing-library/react' ;
11+ import { screen } from '@testing-library/react' ;
12+ import userEvent from '@testing-library/user-event' ;
1213import { renderWithEuiTheme } from '@kbn/test-jest-helpers' ;
1314
1415import { CalendarPanel } from './calendar_panel' ;
1516import { DATE_TYPE_ABSOLUTE , DATE_TYPE_NOW , DATE_TYPE_RELATIVE } from '../constants' ;
16- import { formatDateRange , formatAbsoluteDate } from '../utils' ;
17+ import { formatDateRange } from '../utils' ;
1718import { textToTimeRange } from '../parse' ;
1819
1920const mockUseDateRangePickerContext = jest . fn ( ) ;
21+ const mockCalendarRangeSpy = jest . fn ( ) ;
2022
2123jest . mock ( '../date_range_picker_context' , ( ) => ( {
2224 useDateRangePickerContext : ( ) => mockUseDateRangePickerContext ( ) ,
@@ -66,6 +68,8 @@ jest.mock('../calendar', () => {
6668 range : { from ?: Date ; to ?: Date } | undefined ;
6769 onRangeChange : ( r : { from ?: Date ; to ?: Date } | undefined ) => void ;
6870 } ) {
71+ mockCalendarRangeSpy ( range ) ;
72+
6973 const handleClick = ( day : number ) => {
7074 const date = new Date ( 2026 , 1 , day ) ;
7175
@@ -92,19 +96,12 @@ jest.mock('../calendar', () => {
9296const feb2026 = ( day : number , h : number , m : number , s = 0 , ms = 0 ) =>
9397 new Date ( 2026 , 1 , day , h , m , s , ms ) ;
9498
95- /** Builds the formatted display string for a given Feb 2026 date. */
96- const feb2026Display = ( day : number , h : number , m : number , s = 0 , ms = 0 ) =>
97- formatAbsoluteDate ( feb2026 ( day , h , m , s , ms ) ) ;
98-
99- /** Builds the UTC ISO string for a given Feb 2026 date. */
100- const feb2026ISO = ( day : number , h : number , m : number , s = 0 , ms = 0 ) =>
101- feb2026 ( day , h , m , s , ms ) . toISOString ( ) ;
99+ describe ( 'CalendarPanel' , ( ) => {
100+ let user : ReturnType < typeof userEvent . setup > ;
102101
103- /** Click a day by its number in the February 2026 calendar. */
104- const clickDay = ( day : number ) =>
105- fireEvent . click ( screen . getByRole ( 'button' , { name : String ( day ) } ) ) ;
102+ /** Click a day by its number in the February 2026 calendar. */
103+ const clickDay = ( day : number ) => user . click ( screen . getByRole ( 'button' , { name : String ( day ) } ) ) ;
106104
107- describe ( 'CalendarPanel' , ( ) => {
108105 const applyRange = jest . fn ( ) ;
109106 const onPresetSave = jest . fn ( ) ;
110107 const setText = jest . fn ( ( newText : string ) => {
@@ -155,9 +152,11 @@ describe('CalendarPanel', () => {
155152 } ) ;
156153
157154 beforeEach ( ( ) => {
155+ user = userEvent . setup ( ) ;
158156 applyRange . mockClear ( ) ;
159157 onPresetSave . mockClear ( ) ;
160158 setText . mockClear ( ) ;
159+ mockCalendarRangeSpy . mockClear ( ) ;
161160 mockUseDateRangePickerContext . mockReturnValue (
162161 makeContext ( [ DATE_TYPE_ABSOLUTE , DATE_TYPE_ABSOLUTE ] )
163162 ) ;
@@ -181,7 +180,7 @@ describe('CalendarPanel', () => {
181180 } ) ;
182181
183182 describe ( 'date normalization' , ( ) => {
184- it ( 'always uses start/end of day when selecting new dates' , ( ) => {
183+ it ( 'always uses start/end of day when selecting new dates' , async ( ) => {
185184 mockUseDateRangePickerContext . mockReturnValue (
186185 makeContext (
187186 [ DATE_TYPE_ABSOLUTE , DATE_TYPE_ABSOLUTE ] ,
@@ -191,20 +190,20 @@ describe('CalendarPanel', () => {
191190 ) ;
192191 renderWithEuiTheme ( < CalendarPanel /> ) ;
193192
194- clickDay ( 10 ) ;
195- clickDay ( 15 ) ;
193+ await clickDay ( 10 ) ;
194+ await clickDay ( 15 ) ;
196195
197196 expect ( setText ) . toHaveBeenLastCalledWith (
198197 formatDateRange ( feb2026 ( 10 , 0 , 0 , 0 , 0 ) , feb2026 ( 15 , 23 , 59 , 59 , 999 ) )
199198 ) ;
200199 } ) ;
201200
202- it ( 'uses start/end of day when selecting the same day twice' , ( ) => {
201+ it ( 'uses start/end of day when selecting the same day twice' , async ( ) => {
203202 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
204203 renderWithEuiTheme ( < CalendarPanel /> ) ;
205204
206- clickDay ( 10 ) ;
207- clickDay ( 10 ) ;
205+ await clickDay ( 10 ) ;
206+ await clickDay ( 10 ) ;
208207
209208 expect ( setText ) . toHaveBeenLastCalledWith (
210209 formatDateRange ( feb2026 ( 10 , 0 , 0 , 0 , 0 ) , feb2026 ( 10 , 23 , 59 , 59 , 999 ) )
@@ -213,28 +212,30 @@ describe('CalendarPanel', () => {
213212 } ) ;
214213
215214 describe ( 'date selection' , ( ) => {
216- it ( 'calls setText with just the start date when only the first date is clicked' , ( ) => {
215+ it ( 'calls setText with a full-day range when only the first date is clicked' , async ( ) => {
217216 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
218217 renderWithEuiTheme ( < CalendarPanel /> ) ;
219218
220- clickDay ( 10 ) ;
219+ await clickDay ( 10 ) ;
221220
222- expect ( setText ) . toHaveBeenCalledWith ( feb2026Display ( 10 , 0 , 0 ) ) ;
221+ expect ( setText ) . toHaveBeenCalledWith (
222+ formatDateRange ( feb2026 ( 10 , 0 , 0 ) , feb2026 ( 10 , 23 , 59 , 59 , 999 ) )
223+ ) ;
223224 } ) ;
224225
225- it ( 'calls setText with the formatted range after both dates are selected' , ( ) => {
226+ it ( 'calls setText with the formatted range after both dates are selected' , async ( ) => {
226227 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
227228 renderWithEuiTheme ( < CalendarPanel /> ) ;
228229
229- clickDay ( 10 ) ;
230- clickDay ( 15 ) ;
230+ await clickDay ( 10 ) ;
231+ await clickDay ( 15 ) ;
231232
232233 expect ( setText ) . toHaveBeenLastCalledWith (
233234 formatDateRange ( feb2026 ( 10 , 0 , 0 ) , feb2026 ( 15 , 23 , 59 , 59 , 999 ) )
234235 ) ;
235236 } ) ;
236237
237- it ( 'resets range and shows new start date when clicking after a complete selection' , ( ) => {
238+ it ( 'resets to a new full-day range when clicking after a complete selection' , async ( ) => {
238239 mockUseDateRangePickerContext . mockReturnValue (
239240 makeContext (
240241 [ DATE_TYPE_ABSOLUTE , DATE_TYPE_ABSOLUTE ] ,
@@ -244,39 +245,48 @@ describe('CalendarPanel', () => {
244245 ) ;
245246 renderWithEuiTheme ( < CalendarPanel /> ) ;
246247
247- clickDay ( 20 ) ;
248+ await clickDay ( 20 ) ;
248249
249- expect ( setText ) . toHaveBeenCalledWith ( feb2026Display ( 20 , 0 , 0 , 0 , 0 ) ) ;
250+ expect ( setText ) . toHaveBeenCalledWith (
251+ formatDateRange ( feb2026 ( 20 , 0 , 0 ) , feb2026 ( 20 , 23 , 59 , 59 , 999 ) )
252+ ) ;
250253 } ) ;
251254 } ) ;
252255
253- describe ( 'Apply button' , ( ) => {
254- it ( 'is disabled when no dates are selected' , ( ) => {
255- mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
256+ describe ( 'invalid range' , ( ) => {
257+ it ( 'leaves the calendar unselected when the range is invalid' , ( ) => {
258+ // End date in the future
259+ mockUseDateRangePickerContext . mockReturnValue ( {
260+ ...makeContext ( [ DATE_TYPE_ABSOLUTE , DATE_TYPE_NOW ] ) ,
261+ timeRange : {
262+ startDate : feb2026 ( 20 , 0 , 0 ) ,
263+ endDate : feb2026 ( 15 , 23 , 59 , 59 , 999 ) ,
264+ type : [ DATE_TYPE_ABSOLUTE , DATE_TYPE_NOW ] as [ string , string ] ,
265+ isInvalid : true ,
266+ } ,
267+ } ) ;
268+
256269 renderWithEuiTheme ( < CalendarPanel /> ) ;
257270
258- expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . toBeDisabled ( ) ;
271+ expect ( mockCalendarRangeSpy ) . toHaveBeenLastCalledWith ( undefined ) ;
259272 } ) ;
273+ } ) ;
260274
261- it ( 'is disabled when only the start date is selected' , ( ) => {
275+ describe ( 'Apply button' , ( ) => {
276+ it ( 'is disabled when no dates are selected' , ( ) => {
262277 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
263278 renderWithEuiTheme ( < CalendarPanel /> ) ;
264279
265- clickDay ( 10 ) ;
266-
267280 expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . toBeDisabled ( ) ;
268281 } ) ;
269282
270- it ( 'shows tooltip when disabled due to missing end date' , ( ) => {
283+ it ( 'is enabled after a single day is selected (full-day range)' , async ( ) => {
271284 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
272285 renderWithEuiTheme ( < CalendarPanel /> ) ;
273286
274- clickDay ( 10 ) ;
287+ await clickDay ( 10 ) ;
275288
276- const applyButton = screen . getByRole ( 'button' , { name : 'Apply' } ) ;
277- expect (
278- applyButton . closest ( '[class*="euiToolTip"]' ) || applyButton . parentElement
279- ) . toBeTruthy ( ) ;
289+ expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . not . toBeDisabled ( ) ;
280290 } ) ;
281291
282292 it ( 'is enabled when initialized with a valid date range' , ( ) => {
@@ -285,64 +295,85 @@ describe('CalendarPanel', () => {
285295 expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . not . toBeDisabled ( ) ;
286296 } ) ;
287297
288- it ( 'becomes enabled after both dates are selected' , ( ) => {
298+ it ( 'becomes enabled as soon as a day is selected and stays enabled for a range' , async ( ) => {
289299 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
290300 renderWithEuiTheme ( < CalendarPanel /> ) ;
291301
292302 expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . toBeDisabled ( ) ;
293303
294- clickDay ( 10 ) ;
295- expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . toBeDisabled ( ) ;
304+ await clickDay ( 10 ) ;
305+ expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . not . toBeDisabled ( ) ;
296306
297- clickDay ( 15 ) ;
307+ await clickDay ( 15 ) ;
298308 expect ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) . not . toBeDisabled ( ) ;
299309 } ) ;
300310
301- it ( 'calls applyRange with UTC ISO bounds and formatted display text' , ( ) => {
311+ it ( 'delegates to applyRange() so the current input range is applied' , async ( ) => {
302312 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
303313 renderWithEuiTheme ( < CalendarPanel /> ) ;
304314
305- clickDay ( 10 ) ;
306- clickDay ( 15 ) ;
307- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
315+ await clickDay ( 10 ) ;
316+ await clickDay ( 15 ) ;
317+ await user . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
308318
309- expect ( applyRange ) . toHaveBeenCalledWith (
310- {
311- start : feb2026ISO ( 10 , 0 , 0 ) ,
312- end : feb2026ISO ( 15 , 23 , 59 , 59 , 999 ) ,
313- } ,
314- formatDateRange ( feb2026 ( 10 , 0 , 0 ) , feb2026 ( 15 , 23 , 59 , 59 , 999 ) )
315- ) ;
319+ // Applies the resolved range from `text` (matching the Enter key)
320+ expect ( applyRange ) . toHaveBeenCalledWith ( ) ;
316321 } ) ;
317322
318- it ( 'calls onPresetSave when Save as preset is checked' , ( ) => {
323+ it ( 'calls onPresetSave when Save as preset is checked' , async ( ) => {
319324 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
320325 renderWithEuiTheme ( < CalendarPanel /> ) ;
321326
322- fireEvent . click ( screen . getByRole ( 'checkbox' , { name : 'Save as preset' } ) ) ;
323- clickDay ( 10 ) ;
324- clickDay ( 15 ) ;
325- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
327+ await user . click ( screen . getByRole ( 'checkbox' , { name : 'Save as preset' } ) ) ;
328+ await clickDay ( 10 ) ;
329+ await clickDay ( 15 ) ;
330+ await user . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
326331
327332 expect ( onPresetSave ) . toHaveBeenCalledWith (
328333 expect . objectContaining ( { label : expect . any ( String ) } )
329334 ) ;
330335 } ) ;
331336
332- it ( 'does not call onPresetSave when Save as preset is unchecked' , ( ) => {
337+ it ( 'does not call onPresetSave when Save as preset is unchecked' , async ( ) => {
333338 mockUseDateRangePickerContext . mockReturnValue ( makeContextNoDates ( ) ) ;
334339 renderWithEuiTheme ( < CalendarPanel /> ) ;
335340
336- clickDay ( 10 ) ;
337- clickDay ( 15 ) ;
338- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
341+ await clickDay ( 10 ) ;
342+ await clickDay ( 15 ) ;
343+ await user . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
339344
340345 expect ( onPresetSave ) . not . toHaveBeenCalled ( ) ;
341346 } ) ;
347+
348+ it ( 'preserves manually edited times when applying (does not floor to day boundaries)' , async ( ) => {
349+ // Mid-day times, clearly distinct from the 00:00:00 / 23:59:59 day
350+ // boundaries, simulate the user editing the time in the input after
351+ // selecting days in the calendar.
352+ const editedStart = feb2026 ( 11 , 9 , 15 , 42 , 0 ) ;
353+ const editedEnd = feb2026 ( 13 , 17 , 48 , 8 , 0 ) ;
354+
355+ mockUseDateRangePickerContext . mockReturnValue (
356+ makeContext ( [ DATE_TYPE_ABSOLUTE , DATE_TYPE_ABSOLUTE ] , editedStart , editedEnd )
357+ ) ;
358+ renderWithEuiTheme ( < CalendarPanel /> ) ;
359+
360+ await user . click ( screen . getByRole ( 'checkbox' , { name : 'Save as preset' } ) ) ;
361+ await user . click ( screen . getByRole ( 'button' , { name : 'Apply' } ) ) ;
362+
363+ expect ( applyRange ) . toHaveBeenCalledWith ( ) ;
364+
365+ // The edited times are preserved when saving as a preset, not floored
366+ expect ( onPresetSave ) . toHaveBeenCalledWith (
367+ expect . objectContaining ( {
368+ start : editedStart . toISOString ( ) ,
369+ end : editedEnd . toISOString ( ) ,
370+ } )
371+ ) ;
372+ } ) ;
342373 } ) ;
343374
344375 describe ( 'back navigation' , ( ) => {
345- it ( 'restores original text when going back' , ( ) => {
376+ it ( 'restores original text when going back' , async ( ) => {
346377 const originalText = 'Last 15 minutes' ;
347378
348379 mockUseDateRangePickerContext . mockReturnValue ( {
@@ -354,7 +385,7 @@ describe('CalendarPanel', () => {
354385
355386 setText . mockClear ( ) ;
356387
357- fireEvent . click ( screen . getByTestId ( 'back-button' ) ) ;
388+ await user . click ( screen . getByTestId ( 'back-button' ) ) ;
358389
359390 expect ( setText ) . toHaveBeenCalledWith ( originalText ) ;
360391 } ) ;
0 commit comments