diff --git a/changelogs/upcoming/7502.md b/changelogs/upcoming/7502.md new file mode 100644 index 00000000000..f4ff0ca7b86 --- /dev/null +++ b/changelogs/upcoming/7502.md @@ -0,0 +1 @@ +- Updated `EuiSuperDatePicker` with a new `canRoundRelativeUnits` prop, which defaults to true (current behavior). To preserve displaying the unit that users select for relative time, set this to false. diff --git a/src-docs/src/views/super_date_picker/playground.js b/src-docs/src/views/super_date_picker/playground.js index b593cdc7924..31a5ab6f0ea 100644 --- a/src-docs/src/views/super_date_picker/playground.js +++ b/src-docs/src/views/super_date_picker/playground.js @@ -32,6 +32,13 @@ export const superDatePickerConfig = () => { value: true, }; + propsToUse.canRoundRelativeUnits = { + ...propsToUse.canRoundRelativeUnits, + type: PropTypes.Boolean, + defaultValue: true, + value: true, + }; + propsToUse.locale = { ...propsToUse.locale, type: PropTypes.String, diff --git a/src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx b/src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx index 536e1e178fb..e238d0df208 100644 --- a/src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx +++ b/src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx @@ -38,6 +38,7 @@ export interface EuiDatePopoverButtonProps { onPopoverClose: EuiPopoverProps['closePopover']; onPopoverToggle: MouseEventHandler; position: 'start' | 'end'; + canRoundRelativeUnits?: boolean; roundUp?: boolean; timeFormat: string; value: string; @@ -56,6 +57,7 @@ export const EuiDatePopoverButton: FunctionComponent< needsUpdating, value, buttonProps, + canRoundRelativeUnits, roundUp, onChange, locale, @@ -82,12 +84,11 @@ export const EuiDatePopoverButton: FunctionComponent< }, ]); - const formattedValue = useFormatTimeString( - value, - dateFormat, + const formattedValue = useFormatTimeString(value, dateFormat, { roundUp, - locale - ); + locale, + canRoundRelativeUnits, + }); let title = formattedValue; const invalidTitle = useEuiI18n( @@ -133,6 +134,7 @@ export const EuiDatePopoverButton: FunctionComponent< void; + canRoundRelativeUnits?: boolean; roundUp?: boolean; dateFormat: string; timeFormat: string; @@ -41,6 +42,7 @@ export const EuiDatePopoverContent: FunctionComponent< EuiDatePopoverContentProps > = ({ value, + canRoundRelativeUnits = true, roundUp = false, onChange, dateFormat, @@ -108,7 +110,9 @@ export const EuiDatePopoverContent: FunctionComponent< { ).toBe(false); }); }); + +describe('useFormatTimeString', () => { + it('it takes a time string and formats it into a humanized date', () => { + expect( + renderHook(() => useFormatTimeString('now-3s', dateFormat)).result.current + ).toEqual('~ a few seconds ago'); + expect( + renderHook(() => useFormatTimeString('now+1m', dateFormat)).result.current + ).toEqual('~ in a minute'); + expect( + renderHook(() => useFormatTimeString('now+100w', dateFormat)).result + .current + ).toEqual('~ in 2 years'); + }); + + it("always parses the 'now' string as-is", () => { + expect( + renderHook(() => useFormatTimeString('now', dateFormat)).result.current + ).toEqual('now'); + }); + + describe('options', () => { + test('locale', () => { + expect( + renderHook(() => + useFormatTimeString('now+15m', dateFormat, { locale: 'ja' }) + ).result.current + ).toBe('~ 15分後'); + }); + + describe('canRoundRelativeUnits', () => { + const option = { canRoundRelativeUnits: false }; + + it("allows skipping moment.fromNow()'s default rounding", () => { + expect( + renderHook(() => useFormatTimeString('now-3s', dateFormat, option)) + .result.current + ).toEqual('3 seconds ago'); + expect( + renderHook(() => useFormatTimeString('now+1m', dateFormat, option)) + .result.current + ).toEqual('in a minute'); + expect( + renderHook(() => useFormatTimeString('now+100w', dateFormat, option)) + .result.current + ).toEqual('in 100 weeks'); + }); + }); + }); +}); diff --git a/src/components/date_picker/super_date_picker/pretty_duration.tsx b/src/components/date_picker/super_date_picker/pretty_duration.tsx index e64d1b9f568..3f6f36d2ef0 100644 --- a/src/components/date_picker/super_date_picker/pretty_duration.tsx +++ b/src/components/date_picker/super_date_picker/pretty_duration.tsx @@ -8,7 +8,7 @@ import React from 'react'; import dateMath from '@elastic/datemath'; -import moment, { LocaleSpecifier } from 'moment'; // eslint-disable-line import/named +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'; @@ -146,9 +146,18 @@ const ISO_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; export const useFormatTimeString = ( timeString: string, dateFormat: string, - roundUp = false, - locale: LocaleSpecifier = 'en' + options?: { + locale?: LocaleSpecifier; + roundUp?: boolean; + canRoundRelativeUnits?: boolean; + } ): string => { + const { + locale = 'en', + roundUp = false, + canRoundRelativeUnits = true, + } = options || {}; + // i18n'd strings const nowDisplay = useEuiI18n('euiPrettyDuration.now', 'now'); const invalidDateDisplay = useEuiI18n( @@ -171,7 +180,27 @@ export const useFormatTimeString = ( } if (moment.isMoment(tryParse)) { - return `~ ${tryParse.locale(locale).fromNow()}`; + if (canRoundRelativeUnits) { + return `~ ${tryParse.locale(locale).fromNow()}`; + } else { + // To force a specific unit to be used, we need to skip moment.fromNow() + // entirely and write our own custom moment formatted output. + const { count, unit: _unit } = parseRelativeParts(timeString); + const isFuture = _unit.endsWith('+'); + const unit = isFuture ? _unit.slice(0, -1) : _unit; // We want just the unit letter without the trailing + + + // @see https://momentjs.com/docs/#/customization/relative-time/ + const relativeUnitKey = ( + count === 1 ? unit : unit + unit + ) as RelativeTimeKey; + + // @see https://momentjs.com/docs/#/i18n/locale-data/ + return moment.localeData().pastFuture( + isFuture ? count : count * -1, + moment.localeData().relativeTime(count, false, relativeUnitKey, false) + // Booleans don't seem to actually matter for output, .pastFuture() handles that + ); + } } return timeString; @@ -246,7 +275,7 @@ export const usePrettyDuration = ({ * If it's none of the above, display basic fallback copy */ const displayFrom = useFormatTimeString(timeFrom, dateFormat); - const displayTo = useFormatTimeString(timeTo, dateFormat, true); + const displayTo = useFormatTimeString(timeTo, dateFormat, { roundUp: true }); const fallbackDuration = useEuiI18n( 'euiPrettyDuration.fallbackDuration', '{displayFrom} to {displayTo}', diff --git a/src/components/date_picker/super_date_picker/super_date_picker.test.tsx b/src/components/date_picker/super_date_picker/super_date_picker.test.tsx index 9156c1f622c..418089185fa 100644 --- a/src/components/date_picker/super_date_picker/super_date_picker.test.tsx +++ b/src/components/date_picker/super_date_picker/super_date_picker.test.tsx @@ -32,6 +32,12 @@ const findInternalInstance = ( }; describe('EuiSuperDatePicker', () => { + // RTL doesn't automatically clean up portals/datepicker popovers between tests + afterEach(() => { + const portals = document.querySelectorAll('[data-euiportal]'); + portals.forEach((portal) => portal.parentNode?.removeChild(portal)); + }); + shouldRenderCustomStyles(, { skip: { style: true }, }); @@ -313,5 +319,59 @@ describe('EuiSuperDatePicker', () => { expect(container.firstChild).toMatchSnapshot(); }); }); + + describe('canRoundRelativeUnits', () => { + const props = { + onTimeChange: noop, + start: 'now-300m', + end: 'now', + }; + + it('defaults to true, which will round relative units up to the next largest unit', () => { + const { getByTestSubject } = render( + + ); + fireEvent.click(getByTestSubject('superDatePickerShowDatesButton')); + + const startButton = getByTestSubject( + 'superDatePickerstartDatePopoverButton' + ); + expect(startButton).toHaveTextContent('~ 5 hours ago'); + + const countInput = getByTestSubject( + 'superDatePickerRelativeDateInputNumber' + ); + expect(countInput).toHaveValue(5); + + const unitSelect = getByTestSubject( + 'superDatePickerRelativeDateInputUnitSelector' + ); + expect(unitSelect).toHaveValue('h'); + + fireEvent.change(countInput, { target: { value: 300 } }); + fireEvent.change(unitSelect, { target: { value: 'd' } }); + expect(startButton).toHaveTextContent('~ 10 months ago'); + }); + + it('when false, allows preserving the unit set in the start/end time timestamp', () => { + const { getByTestSubject } = render( + + ); + fireEvent.click(getByTestSubject('superDatePickerShowDatesButton')); + + const startButton = getByTestSubject( + 'superDatePickerstartDatePopoverButton' + ); + expect(startButton).toHaveTextContent('300 minutes ago'); + + const unitSelect = getByTestSubject( + 'superDatePickerRelativeDateInputUnitSelector' + ); + expect(unitSelect).toHaveValue('m'); + + fireEvent.change(unitSelect, { target: { value: 'd' } }); + expect(startButton).toHaveTextContent('300 days ago'); + }); + }); }); }); diff --git a/src/components/date_picker/super_date_picker/super_date_picker.tsx b/src/components/date_picker/super_date_picker/super_date_picker.tsx index 6e75c2e6396..4c7af4498e3 100644 --- a/src/components/date_picker/super_date_picker/super_date_picker.tsx +++ b/src/components/date_picker/super_date_picker/super_date_picker.tsx @@ -179,6 +179,15 @@ export type EuiSuperDatePickerProps = CommonProps & { * Props passed to the update button #EuiSuperUpdateButtonProps */ updateButtonProps?: EuiSuperUpdateButtonProps; + + /** + * By default, relative units will be rounded up to next largest unit of time + * (for example, 90 minutes will become ~ 2 hours). + * + * If you do not want this behavior and instead wish to keep the exact units + * input by the user, set this flag to `false`. + */ + canRoundRelativeUnits?: boolean; }; type EuiSuperDatePickerInternalProps = EuiSuperDatePickerProps & { @@ -241,6 +250,7 @@ export class EuiSuperDatePickerInternal extends Component< recentlyUsedRanges: [], refreshInterval: 1000, showUpdateButton: true, + canRoundRelativeUnits: true, start: 'now-15m', timeFormat: 'HH:mm', width: 'restricted', @@ -468,6 +478,7 @@ export class EuiSuperDatePickerInternal extends Component< isQuickSelectOnly, showUpdateButton, commonlyUsedRanges, + canRoundRelativeUnits, timeOptions, dateFormat, refreshInterval, @@ -562,6 +573,7 @@ export class EuiSuperDatePickerInternal extends Component< utcOffset={utcOffset} timeFormat={timeFormat} locale={locale || contextLocale} + canRoundRelativeUnits={canRoundRelativeUnits} isOpen={this.state.isStartDatePopoverOpen} onPopoverToggle={this.onStartDatePopoverToggle} onPopoverClose={this.onStartDatePopoverClose} @@ -582,6 +594,7 @@ export class EuiSuperDatePickerInternal extends Component< utcOffset={utcOffset} timeFormat={timeFormat} locale={locale || contextLocale} + canRoundRelativeUnits={canRoundRelativeUnits} roundUp isOpen={this.state.isEndDatePopoverOpen} onPopoverToggle={this.onEndDatePopoverToggle}