Skip to content

Commit d25d562

Browse files
acstllclaudeelasticmachine
authored
[DateRangePicker] Calendar "Apply" button preserves edited times (elastic#273335)
## Summary This PR addresses two UX issues in the Calendar panel of the `DateRangePicker`: - **Manual changes made to time** in the input control are kept when clicking the "Apply" button (previously, these changes were discarded) - **A single-click to a day** in the calendar view will create a single-day range, so clicking "Apply" right away is possible (clicking again on the same day will confirm the same range, keeping the previous UX untouched) ### Clips <img width="1030" height="1030" alt="Kapture 2026-06-15 at 15 22 29" src="https://github.com/user-attachments/assets/2067387d-86eb-4806-bcd4-b1ab7feaf7d2" /> Manual edit to time and "Apply" button <img width="1030" height="1030" alt="Kapture 2026-06-15 at 15 24 21" src="https://github.com/user-attachments/assets/d5ccd26f-66c3-47fa-aa4c-69dd37e7daa3" /> Single-click same-day range Addresses elastic/eui-private#673 ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks Mainly a UX change, no risks identified. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent 9af39e2 commit d25d562

7 files changed

Lines changed: 134 additions & 127 deletions

File tree

src/platform/packages/shared/shared-ux/datetime/kbn-date-range-picker/panels/calendar_panel.test.tsx

Lines changed: 98 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
*/
99

1010
import 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';
1213
import { renderWithEuiTheme } from '@kbn/test-jest-helpers';
1314

1415
import { CalendarPanel } from './calendar_panel';
1516
import { DATE_TYPE_ABSOLUTE, DATE_TYPE_NOW, DATE_TYPE_RELATIVE } from '../constants';
16-
import { formatDateRange, formatAbsoluteDate } from '../utils';
17+
import { formatDateRange } from '../utils';
1718
import { textToTimeRange } from '../parse';
1819

1920
const mockUseDateRangePickerContext = jest.fn();
21+
const mockCalendarRangeSpy = jest.fn();
2022

2123
jest.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', () => {
9296
const 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

Comments
 (0)