diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png index d2984d4a0be..c8987f6687e 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png new file mode 100644 index 00000000000..05ef003b1d3 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png index 4a0009ba5d4..3544b765912 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png and b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png new file mode 100644 index 00000000000..94bac6e6ed1 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png differ diff --git a/packages/eui/changelogs/upcoming/9151.md b/packages/eui/changelogs/upcoming/9151.md new file mode 100644 index 00000000000..548300f184c --- /dev/null +++ b/packages/eui/changelogs/upcoming/9151.md @@ -0,0 +1,2 @@ +- Updated `EuiSuperDatePicker` with new time window buttons for time shifting and zoom out, opt-in via `showTimeWindowButtons` boolean prop. + diff --git a/packages/eui/src/components/date_picker/super_date_picker/__snapshots__/time_window_buttons.test.tsx.snap b/packages/eui/src/components/date_picker/super_date_picker/__snapshots__/time_window_buttons.test.tsx.snap new file mode 100644 index 00000000000..470d1f49ae9 --- /dev/null +++ b/packages/eui/src/components/date_picker/super_date_picker/__snapshots__/time_window_buttons.test.tsx.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimeWindowButtons renders 1`] = ` +
+ + + + + + + + + +
+`; diff --git a/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx b/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx index 3f6f36d2ef0..494ac1ceb6f 100644 --- a/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx +++ b/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx @@ -11,7 +11,7 @@ import dateMath from '@elastic/datemath'; import moment, { LocaleSpecifier, RelativeTimeKey } from 'moment'; // eslint-disable-line import/named import { useEuiI18n } from '../../i18n'; import { getDateMode, DATE_MODES } from './date_modes'; -import { parseRelativeParts } from './relative_utils'; +import { parseRelativeParts, isRelativeToNow } from './relative_utils'; import { useI18nTimeOptions } from './time_options'; import { DurationRange, @@ -315,16 +315,6 @@ const hasRangeMatch = ( return ranges.find(({ start, end }) => timeFrom === start && timeTo === end); }; -const isRelativeToNow = (timeFrom: ShortDate, timeTo: ShortDate): boolean => { - const fromDateMode = getDateMode(timeFrom); - const toDateMode = getDateMode(timeTo); - const isLast = - fromDateMode === DATE_MODES.RELATIVE && toDateMode === DATE_MODES.NOW; - const isNext = - fromDateMode === DATE_MODES.NOW && toDateMode === DATE_MODES.RELATIVE; - return isLast || isNext; -}; - export const showPrettyDuration = ( timeFrom: ShortDate, timeTo: ShortDate, diff --git a/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts b/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts index 9ad0b211f6c..43bd15ef60e 100644 --- a/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts +++ b/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts @@ -11,7 +11,8 @@ import moment from 'moment'; import { get } from '../../../services/objects'; import { isString } from '../../../services/predicate'; -import { TimeUnitId, RelativeParts } from '../types'; +import { TimeUnitId, RelativeParts, ShortDate } from '../types'; +import { getDateMode, DATE_MODES } from './date_modes'; const ROUND_DELIMETER = '/'; @@ -80,3 +81,16 @@ export const toRelativeStringFromParts = (relativeParts: RelativeParts) => { return `now${operator}${count}${unit}${round}`; }; + +export const isRelativeToNow = ( + timeFrom: ShortDate, + timeTo: ShortDate +): boolean => { + const fromDateMode = getDateMode(timeFrom); + const toDateMode = getDateMode(timeTo); + const isLast = + fromDateMode === DATE_MODES.RELATIVE && toDateMode === DATE_MODES.NOW; + const isNext = + fromDateMode === DATE_MODES.NOW && toDateMode === DATE_MODES.RELATIVE; + return isLast || isNext; +}; diff --git a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx index 4168682c27f..d70ffe34b5c 100644 --- a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx +++ b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx @@ -190,6 +190,14 @@ export const QuickSelectOnly: Story = { }, }; +export const TimeWindowButtons: Story = { + args: { + showTimeWindowButtons: true, + showUpdateButton: false, + }, + render: (args) => , +}; + /** * VRT only */ diff --git a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx index 17f0e1e2485..dc72e4e0443 100644 --- a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx +++ b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx @@ -8,7 +8,7 @@ import React, { useState } from 'react'; import moment from 'moment'; -import { fireEvent, act } from '@testing-library/react'; +import { fireEvent, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render, waitForEuiPopoverOpen, screen } from '../../../test/rtl'; @@ -19,6 +19,7 @@ import { EuiSuperDatePicker, EuiSuperDatePickerProps, } from './super_date_picker'; +import { ZOOM_FACTOR_DEFAULT } from './time_window_buttons'; const noop = () => {}; @@ -640,4 +641,136 @@ describe('EuiSuperDatePicker', () => { expect(prevEnd).toBe('2025-01-01T10:00:00.000Z'); }); }); + + describe('Time window buttons', () => { + it('renders only when showTimeWindowButtons prop is passed', () => { + const start = '2025-10-30T12:00:00.000Z'; + const end = '2025-10-30T13:00:00.000Z'; + + const { queryByTestSubject, rerender } = render( + {}} + showTimeWindowButtons + /> + ); + + expect(queryByTestSubject('timeWindowButtons')).toBeInTheDocument(); + + rerender( + {}} /> + ); + + expect(queryByTestSubject('timeWindowButtons')).not.toBeInTheDocument(); + }); + + it('updates time when shifting', async () => { + const start = '2025-10-30T12:00:00.000Z'; + const end = '2025-10-30T13:00:00.000Z'; + const stepBackwardStart = '2025-10-30T11:00:00.000Z'; + const stepBackwardEnd = '2025-10-30T12:00:00.000Z'; + let lastTimeChange: { start: string; end: string } = { start, end }; + + const { getByTestSubject } = render( + { + lastTimeChange = { start, end }; + }} + showUpdateButton={false} + showTimeWindowButtons={true} + /> + ); + + act(() => { + userEvent.click(getByTestSubject('timeWindowButtonsPrevious')); + }); + + await waitFor(() => { + expect(lastTimeChange.end).toEqual(stepBackwardEnd); + expect(lastTimeChange.start).toEqual(stepBackwardStart); + + const initialTimeStart = new Date(start).getTime(); + const updatedTimeStart = new Date(lastTimeChange.start).getTime(); + const initialTimeEnd = new Date(end).getTime(); + const updatedTimeEnd = new Date(lastTimeChange.end).getTime(); + + expect(initialTimeStart).toBeGreaterThan(updatedTimeStart); + expect(initialTimeEnd).toBeGreaterThan(updatedTimeEnd); + // Also check the diff is the same + expect(initialTimeEnd - initialTimeStart).toEqual( + updatedTimeEnd - updatedTimeStart + ); + }); + }); + + it('updates time when zooming out', async () => { + const start = '2025-10-30T12:00:00.000Z'; + const end = '2025-10-31T12:00:00.000Z'; + let lastTimeChange: { start: string; end: string } = { start, end }; + + const { getByTestSubject } = render( + { + lastTimeChange = { start, end }; + }} + showUpdateButton={false} + showTimeWindowButtons={true} + /> + ); + + act(() => { + userEvent.click(getByTestSubject('timeWindowButtonsZoomOut')); + }); + + await waitFor(() => { + const initialTimeStart = new Date(start).getTime(); + const updatedTimeStart = new Date(lastTimeChange.start).getTime(); + const initialTimeEnd = new Date(end).getTime(); + const updatedTimeEnd = new Date(lastTimeChange.end).getTime(); + expect(initialTimeStart).toBeGreaterThan(updatedTimeStart); + expect(initialTimeEnd).toBeLessThan(updatedTimeEnd); + // Check the diff expanded by zoom factor + expect( + (initialTimeEnd - initialTimeStart) * (1 + ZOOM_FACTOR_DEFAULT) + ).toEqual(updatedTimeEnd - updatedTimeStart); + }); + }); + + it('is disabled when date/time range is invalid', async () => { + // reversed range (invalid) + const start = '2025-10-30T14:00:00.000Z'; + const end = '2025-10-31T14:00:00.000Z'; + + const { rerender, getByTestSubject } = render( + {}} + showTimeWindowButtons={true} + /> + ); + + expect(getByTestSubject('timeWindowButtonsPrevious')).toBeDisabled(); + expect(getByTestSubject('timeWindowButtonsZoomOut')).toBeDisabled(); + expect(getByTestSubject('timeWindowButtonsNext')).toBeDisabled(); + + rerender( + {}} + showTimeWindowButtons={true} + /> + ); + + expect(getByTestSubject('timeWindowButtonsPrevious')).not.toBeDisabled(); + expect(getByTestSubject('timeWindowButtonsZoomOut')).not.toBeDisabled(); + expect(getByTestSubject('timeWindowButtonsNext')).not.toBeDisabled(); + }); + }); }); diff --git a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx index 46d20c15cf8..68e130e31d2 100644 --- a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx +++ b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx @@ -39,6 +39,10 @@ import { import { TimeOptions, RenderI18nTimeOptions } from './time_options'; import { PrettyDuration, showPrettyDuration } from './pretty_duration'; +import { + TimeWindowButtons, + type TimeWindowButtonsConfig, +} from './time_window_buttons'; import { AsyncInterval } from './async_interval'; import { @@ -202,6 +206,12 @@ export type EuiSuperDatePickerProps = CommonProps & { */ showUpdateButton?: boolean | 'iconOnly'; + /** + * Set to true to display buttons for time shifting and zooming out, + * next to the top-level control. + */ + showTimeWindowButtons?: boolean | TimeWindowButtonsConfig; + /** * Hides the actual input reducing to just the quick select button. */ @@ -725,6 +735,27 @@ export class EuiSuperDatePickerInternal extends Component< } }; + renderTimeWindowButtons = () => { + if (!this.props.showTimeWindowButtons || this.props.isAutoRefreshOnly) { + return null; + } + const { start, end, showTimeWindowButtons, compressed, isDisabled } = + this.props; + const config = + typeof showTimeWindowButtons === 'boolean' ? {} : showTimeWindowButtons; + + return ( + + ); + }; + renderUpdateButton = () => { const { isLoading, @@ -805,6 +836,7 @@ export class EuiSuperDatePickerInternal extends Component< ) : ( <> {this.renderDatePickerRange()} + {this.renderTimeWindowButtons()} {this.renderUpdateButton()} )} diff --git a/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx new file mode 100644 index 00000000000..0be19f9c9e4 --- /dev/null +++ b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import moment from 'moment'; +import { act, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + render, + renderHook, + renderHookAct, + waitForEuiToolTipVisible, +} from '../../../test/rtl'; + +import { + TimeWindowButtons, + useTimeWindow, + ZOOM_FACTOR_DEFAULT, +} from './time_window_buttons'; + +describe('TimeWindowButtons: useTimeWindow hook', () => { + describe('displayInterval', () => { + it('handles relative times', () => { + const applyTime = jest.fn(); + const start = 'now-15m'; + const end = 'now'; + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + expect(result.current.displayInterval).toBe('15 minutes'); + }); + + it('handles absolute times', () => { + const applyTime = jest.fn(); + const start = '2025-10-29T16:00:00.000Z'; + const end = '2025-10-29T16:15:00.000Z'; + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + expect(result.current.displayInterval).toBe('15 minutes'); + }); + + it('handles invalid time (undefined)', () => { + const applyTime = jest.fn(); + const start = undefined; + const end = '2025-10-29T16:15:00.000Z'; + + // @ts-expect-error - intentionally testing with undefined start value + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + expect(result.current.displayInterval).toBe(''); + expect(result.current.isInvalid).toBeTruthy(); + }); + + it('handles invalid time', () => { + const applyTime = jest.fn(); + const start = '2025-10-29T16:00:00.000Z'; + const end = 'not a date'; + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + expect(result.current.displayInterval).toBe(''); + expect(result.current.isInvalid).toBeTruthy(); + }); + + it('adds a tilde for approximate ranges', () => { + const applyTime = jest.fn(); + const start = '2025-10-27T16:00:01.000Z'; + const end = '2025-10-29T16:12:00.000Z'; + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + expect(result.current.displayInterval).toBe('~2 days'); + }); + }); + + describe('stepForward callback', () => { + it('shifts time window forward', () => { + const applyTime = jest.fn(); + const start = '2025-10-30T10:00:00.000Z'; + const end = '2025-10-30T11:00:00.000Z'; + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + renderHookAct(() => { + result.current.stepForward(); + }); + + expect(applyTime).toHaveBeenCalledWith({ + start: '2025-10-30T11:00:00.000Z', + end: '2025-10-30T12:00:00.000Z', + }); + }); + }); + + describe('stepBackward callback', () => { + it('shifts time window backward', () => { + const applyTime = jest.fn(); + const start = '2025-10-30T10:00:00.000Z'; + const end = '2025-10-30T11:00:00.000Z'; + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + renderHookAct(() => { + result.current.stepBackward(); + }); + + expect(applyTime).toHaveBeenCalledWith({ + start: '2025-10-30T09:00:00.000Z', + end: '2025-10-30T10:00:00.000Z', + }); + }); + }); + + describe('expandWindow callback', () => { + it('expands time window on both ends of the range', () => { + const applyTime = jest.fn(); + const start = '2025-10-30T10:00:00.000Z'; + const end = '2025-10-30T11:00:00.000Z'; + + const shiftedStart = moment(start).subtract( + ZOOM_FACTOR_DEFAULT / 2, + 'hours' + ); + const shiftedEnd = moment(end).add(ZOOM_FACTOR_DEFAULT / 2, 'hours'); + + const { result } = renderHook(() => useTimeWindow(start, end, applyTime)); + + renderHookAct(() => { + result.current.expandWindow(); + }); + + expect(applyTime).toHaveBeenCalledWith({ + start: shiftedStart.toISOString(), + end: shiftedEnd.toISOString(), + }); + }); + + it('handles different zoom factor option', () => { + const customZoomFactor = 0.42; + const applyTime = jest.fn(); + const start = '2025-10-30T10:00:00.000Z'; + const end = '2025-10-30T11:00:00.000Z'; + + const shiftedStart = moment(start).subtract( + customZoomFactor / 2, + 'hours' + ); + const shiftedEnd = moment(end).add(customZoomFactor / 2, 'hours'); + + const { result } = renderHook(() => + useTimeWindow(start, end, applyTime, { zoomFactor: customZoomFactor }) + ); + + renderHookAct(() => { + result.current.expandWindow(); + }); + + expect(applyTime).toHaveBeenCalledWith({ + start: shiftedStart.toISOString(), + end: shiftedEnd.toISOString(), + }); + }); + }); +}); + +describe('TimeWindowButtons', () => { + it('renders', () => { + const start = 'now-15m'; + const end = 'now'; + + const { container } = render( + {}} /> + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + // This will not happen at all, because any invalid time range will toggle the buttons disabled, + // but we provide it in case requirements change + it('handles invalid times gracefully', async () => { + const apply = jest.fn(); + const start = 'not a date'; + const end = 'now'; + + const { getByTestSubject, getByRole } = render( + + ); + + act(() => { + userEvent.click(getByTestSubject('timeWindowButtonsPrevious')); + userEvent.click(getByTestSubject('timeWindowButtonsZoomOut')); + userEvent.click(getByTestSubject('timeWindowButtonsNext')); + }); + + expect(apply).not.toHaveBeenCalled(); + + fireEvent.mouseEnter(getByTestSubject('timeWindowButtonsZoomOut')); + await waitForEuiToolTipVisible(); + + expect(getByRole('tooltip')).toHaveTextContent(/Cannot/); + }); +}); diff --git a/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.tsx b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.tsx new file mode 100644 index 00000000000..38e0a2e68ba --- /dev/null +++ b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.tsx @@ -0,0 +1,268 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import dateMath from '@elastic/datemath'; +import moment from 'moment'; + +import { ShortDate, ApplyTime } from '../types'; +import { usePrettyInterval } from './pretty_interval'; +import { isRelativeToNow } from './relative_utils'; + +import { EuiButtonGroupButton } from '../../button/button_group/button_group_button'; +import { euiButtonGroupButtonsStyles } from '../../button/button_group/button_group.styles'; +import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../../services'; +import { useEuiI18n } from '../../i18n'; + +export const ZOOM_FACTOR_DEFAULT = 0.5; + +export interface TimeWindowButtonsConfig { + /** + * Show button for zooming out + * @default true + */ + showZoomOut?: boolean; + /** + * Show buttons for shifting the time window forward and backward + * @default true + */ + showShiftArrows?: boolean; + /** + * How much the time window is increased when zooming. + * A number between 0 and 1 e.g. 0.25, or a string representing a percentage e.g. 25% + * @default 0.5 + * */ + zoomFactor?: number | string; +} + +export type TimeWindowButtonsProps = TimeWindowButtonsConfig & { + applyTime: ApplyTime; + start: ShortDate; + end: ShortDate; + compressed?: boolean; + isDisabled?: boolean; +}; + +/** + * Button group with time window controls for shifting the time window + * forwards and backwards, and zooming out. + */ +export const TimeWindowButtons: React.FC = ({ + applyTime, + start, + end, + compressed, + isDisabled, + showZoomOut = true, + showShiftArrows = true, + zoomFactor = ZOOM_FACTOR_DEFAULT, +}) => { + const buttonColor = 'text'; + const buttonSize = compressed ? 's' : 'm'; + const iconSize = compressed ? 's' : 'm'; + const styles = useEuiMemoizedStyles(euiButtonGroupButtonsStyles); + + const { + displayInterval, + isInvalid, + stepForward, + stepBackward, + expandWindow, + } = useTimeWindow(start, end, applyTime, { zoomFactor }); + + const invalidShiftDescription = useEuiI18n( + 'euiTimeWindowButtons.invalidShiftLabel', + 'Cannot shift invalid time window' + ); + const invalidZoomOutDescription = useEuiI18n( + 'euiTimeWindowButtons.invalidZoomOutLabel', + 'Cannot zoom out invalid time window' + ); + + const previousId = useGeneratedHtmlId({ prefix: 'previous' }); + const previousLabel = useEuiI18n( + 'euiTimeWindowButtons.previousLabel', + 'Previous' + ); + const previousTooltipContent = useEuiI18n( + 'euiTimeWindowButtons.previousDescription', + 'Previous {displayInterval}', + { displayInterval } + ); + + const zoomOutId = useGeneratedHtmlId({ prefix: 'zoom_out' }); + const zoomOutLabel = useEuiI18n( + 'euiTimeWindowButtons.zoomOutLabel', + 'Zoom out' + ); + const zoomOutTooltipContent = isInvalid + ? invalidZoomOutDescription + : zoomOutLabel; + + const nextId = useGeneratedHtmlId({ prefix: 'next' }); + const nextLabel = useEuiI18n('euiTimeWindowButtons.nextLabel', 'Next'); + const nextTooltipContent = useEuiI18n( + 'euiTimeWindowButtons.nextDescription', + 'Next {displayInterval}', + { displayInterval } + ); + + if (!showZoomOut && !showShiftArrows) return null; + + return ( +
+ {showShiftArrows && ( + + )} + {showZoomOut && ( + + )} + {showShiftArrows && ( + + )} +
+ ); +}; + +/** + * Partly adapted from date_picker/super_date_picker/quick_select_popover/quick_select.tsx + */ +export function useTimeWindow( + start: ShortDate, + end: ShortDate, + apply: ApplyTime, + options?: { zoomFactor?: TimeWindowButtonsConfig['zoomFactor'] } +) { + const min = dateMath.parse(start); + const max = dateMath.parse(end, { roundUp: true }); + const isInvalid = !min || !min.isValid() || !max || !max.isValid(); + const windowDuration = isInvalid ? 1 : max.diff(min); + const zoomFactor = getPercentageMultiplier( + options?.zoomFactor ?? ZOOM_FACTOR_DEFAULT + ); + const zoomAddition = windowDuration * (zoomFactor / 2); // Gets added to each end, that's why it's split in half + const prettyInterval = usePrettyInterval(false, windowDuration); + let displayInterval = isInvalid ? '' : prettyInterval; + if ( + !isInvalid && + !isRelativeToNow(start, end) && + !isExactMinuteRange(windowDuration) + ) { + displayInterval = `~${displayInterval}`; + } + + return { + displayInterval, + isInvalid, + stepForward, + stepBackward, + expandWindow, + }; + + function stepForward() { + if (isInvalid) return; + apply({ + start: moment(max).toISOString(), + end: moment(max).add(windowDuration, 'ms').toISOString(), + }); + } + + function stepBackward() { + if (isInvalid) return; + apply({ + start: moment(min).subtract(windowDuration, 'ms').toISOString(), + end: moment(min).toISOString(), + }); + } + + function expandWindow() { + if (isInvalid) return; + apply({ + start: moment(min).subtract(zoomAddition, 'ms').toISOString(), + end: moment(max).add(zoomAddition, 'ms').toISOString(), + }); + } +} + +/** + * Get a number out of either 0.2 or "20%" + */ +function getPercentageMultiplier(value: number | string) { + const result = + typeof value === 'number' + ? value + : parseFloat(String(value).replace('%', '').trim()); + if (isNaN(result)) + throw new TypeError( + 'Please provide a valid number or percentage string e.g. "25%"' + ); + return result > 1 ? result / 100 : result; +} + +/** + * Useful to determine whether to show the tilde in the display + */ +function isExactMinuteRange(diffMs: number) { + // 60 * 1000 = ms per minute + return diffMs % (60 * 1000) === 0; +} diff --git a/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx b/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx index 5f84da6fb90..5323c51e36b 100644 --- a/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx +++ b/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx @@ -569,6 +569,59 @@ export default () => { }; ``` +```mdx-code-block +import { ZOOM_FACTOR_DEFAULT } from '@elastic/eui/es/components/date_picker/super_date_picker/time_window_buttons'; +``` + +## Time window buttons + +A button group with buttons to modify the time window (or range) can be shown next to the main control. To show it, pass the `showTimeWindowButtons` prop to the `EuiSuperDatePicker` component. It allows to zoom out the window, or shift it both forwards or backwards. + +Zooming out will expand the window by {ZOOM_FACTOR_DEFAULT * 100}% by default. This is configurable via the `showTimeWindowButtons` prop, by passing a config object with a `zoomFactor` property e.g. `showTimeWindowButtons={{ zoomFactor: 0.42 }}`. + +```tsx interactive +import React, { useState } from 'react'; +import { + EuiSuperDatePicker, + EuiFlexGroup, + EuiSwitch, + EuiSpacer, + OnTimeChangeProps, +} from "@elastic/eui"; + +export default () => { + const [showButtons, setShowButtons] = useState(true); + const [start, setStart] = useState("now-15m"); + const [end, setEnd] = useState("now"); + + const onTimeChange = ({ start, end }: OnTimeChangeProps) => { + setStart(start); + setEnd(end); + }; + + return ( + <> + + setShowButtons(e.target.checked)} + label="Show time window buttons" + /> + + + + + ); +}; +``` + ## Elastic pattern with KQL The following is a demo pattern of how to layout the **EuiSuperDatePicker** alongside Elastic's KQL search bar using [EuiSearchBar](../search-and-filter/search-bar.mdx) and shrinking to fit when the search bar is in focus.