Skip to content

Commit

Permalink
[EuiSuperDatePicker] Allow the Absolute tab to accept standardized …
Browse files Browse the repository at this point in the history
…ISO 8601, RFC 2822, and Unix timestamps (#7331)
  • Loading branch information
cee-chen authored Nov 2, 2023
1 parent 197e104 commit 3e26532
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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 { act, fireEvent } from '@testing-library/react';
import { render } from '../../../../test/rtl';

import { EuiAbsoluteTab } from './absolute_tab';

// Mock EuiDatePicker - 3rd party datepicker lib causes render issues
jest.mock('../../date_picker', () => ({
EuiDatePicker: () => 'EuiDatePicker',
}));

describe('EuiAbsoluteTab', () => {
const props = {
dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS',
timeFormat: 'HH:mm',
value: '',
onChange: () => {},
roundUp: false,
position: 'start' as const,
labelPrefix: 'Start date',
};

describe('user input', () => {
beforeAll(() => jest.useFakeTimers());
afterAll(() => jest.useRealTimers());

const changeInput = (input: HTMLElement, value: string) => {
fireEvent.change(input, { target: { value } });
act(() => {
jest.advanceTimersByTime(1000); // Debounce timer
});
};

it('parses the passed `dateFormat` prop', () => {
const { getByTestSubject } = render(
<EuiAbsoluteTab {...props} dateFormat="MMM Do YY" />
);
const input = getByTestSubject('superDatePickerAbsoluteDateInput');

changeInput(input, 'Jan 31st 01');
expect(input).not.toBeInvalid();
expect(input).toHaveValue('Jan 31st 01');
});

describe('allows several other common date formats, and autoformats them to the `dateFormat` prop', () => {
const assertOutput = (input: HTMLInputElement) => {
// Exclude hours from assertion, because moment uses local machine timezone
expect(input.value).toContain('Jan 1, 1970');
};

test('ISO 8601', () => {
const { getByTestSubject } = render(<EuiAbsoluteTab {...props} />);
const input = getByTestSubject('superDatePickerAbsoluteDateInput');

changeInput(input, '1970-01-01T12:00:00+00:00');
expect(input).not.toBeInvalid();
assertOutput(input as HTMLInputElement);
});

test('RFC 2822', () => {
const { getByTestSubject } = render(<EuiAbsoluteTab {...props} />);
const input = getByTestSubject('superDatePickerAbsoluteDateInput');

changeInput(input, 'Thu, 1 Jan 1970 12:00:00 +0000');
expect(input).not.toBeInvalid();
assertOutput(input as HTMLInputElement);
});

test('unix timestamp', () => {
const { getByTestSubject } = render(<EuiAbsoluteTab {...props} />);
const input = getByTestSubject('superDatePickerAbsoluteDateInput');

changeInput(input, Date.now().toString());
expect(input).not.toBeInvalid();

changeInput(input, '43200');
expect(input).not.toBeInvalid();
assertOutput(input as HTMLInputElement);
});
});

it('flags all other date formats as invalid', () => {
const { getByTestSubject } = render(<EuiAbsoluteTab {...props} />);
const input = getByTestSubject('superDatePickerAbsoluteDateInput');

changeInput(input, '01-01-1970');
expect(input).toHaveValue('01-01-1970');
expect(input).toBeInvalid();

changeInput(input, 'asdfasdf');
expect(input).toHaveValue('asdfasdf');
expect(input).toBeInvalid();

changeInput(input, '');
expect(input).toHaveValue('');
expect(input).toBeInvalid();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@
* Side Public License, v 1.
*/

import React, { Component, ChangeEventHandler } from 'react';
import React, { Component, ChangeEvent } from 'react';

import moment, { Moment, LocaleSpecifier } from 'moment'; // eslint-disable-line import/named

import dateMath from '@elastic/datemath';

import { EuiDatePicker, EuiDatePickerProps } from '../../date_picker';
import { EuiFormRow, EuiFieldText, EuiFormLabel } from '../../../form';
import { EuiCode } from '../../../code';
import { EuiI18n } from '../../../i18n';

import { EuiDatePicker, EuiDatePickerProps } from '../../date_picker';
import { EuiDatePopoverContentProps } from './date_popover_content';

// Allow users to paste in and have the datepicker parse multiple common date formats,
// in addition to the configured displayed `dateFormat` prop
const ALLOWED_USER_DATE_FORMATS = [
moment.ISO_8601,
moment.RFC_2822,
'X', // Unix timestamp in seconds
];

export interface EuiAbsoluteTabProps {
dateFormat: string;
timeFormat: string;
Expand Down Expand Up @@ -59,12 +69,12 @@ export class EuiAbsoluteTab extends Component<
};
}

handleChange: EuiDatePickerProps['onChange'] = (date, event) => {
handleChange: EuiDatePickerProps['onChange'] = (date) => {
const { onChange } = this.props;
if (date === null) {
return;
}
onChange(date.toISOString(), event);
onChange(date.toISOString());

const valueAsMoment = moment(date);
this.setState({
Expand All @@ -74,22 +84,50 @@ export class EuiAbsoluteTab extends Component<
});
};

handleTextChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const { onChange } = this.props;
const valueAsMoment = moment(
event.target.value,
this.props.dateFormat,
true
);
const dateIsValid = valueAsMoment.isValid();
debouncedTypeTimeout: ReturnType<typeof setTimeout> | undefined;

handleTextChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ textInputValue: event.target.value });

// Add a debouncer that gives the user some time to finish typing
// before attempting to parse the text as a timestamp. Otherwise,
// typing a single digit gets parsed as a unix timestamp 😬
clearTimeout(this.debouncedTypeTimeout);
this.debouncedTypeTimeout = setTimeout(this.parseUserDateInput, 1000); // 1 second debounce
};

parseUserDateInput = () => {
const { onChange, dateFormat } = this.props;
const { textInputValue } = this.state;

const invalidDateState = {
isTextInvalid: true,
valueAsMoment: null,
};
if (!textInputValue) {
return this.setState(invalidDateState);
}

// Attempt to parse with passed `dateFormat`
let valueAsMoment = moment(textInputValue, dateFormat, true);
let dateIsValid = valueAsMoment.isValid();

// If not valid, try a few other other standardized formats
if (!dateIsValid) {
valueAsMoment = moment(textInputValue, ALLOWED_USER_DATE_FORMATS, true);
dateIsValid = valueAsMoment.isValid();
}

if (dateIsValid) {
onChange(valueAsMoment.toISOString(), event);
onChange(valueAsMoment.toISOString());
this.setState({
textInputValue: valueAsMoment.format(this.props.dateFormat),
isTextInvalid: false,
valueAsMoment: valueAsMoment,
});
} else {
this.setState(invalidDateState);
}
this.setState({
textInputValue: event.target.value,
isTextInvalid: !dateIsValid,
valueAsMoment: dateIsValid ? valueAsMoment : null,
});
};

render() {
Expand All @@ -98,7 +136,7 @@ export class EuiAbsoluteTab extends Component<
const { valueAsMoment, isTextInvalid, textInputValue } = this.state;

return (
<div>
<>
<EuiDatePicker
inline
showTimeSelect
Expand All @@ -112,8 +150,8 @@ export class EuiAbsoluteTab extends Component<
/>
<EuiI18n
token="euiAbsoluteTab.dateFormatError"
default="Expected format: {dateFormat}"
values={{ dateFormat }}
default="Allowed formats: {dateFormat}, ISO 8601, RFC 2822, or Unix timestamp"
values={{ dateFormat: <EuiCode>{dateFormat}</EuiCode> }}
>
{(dateFormatError: string) => (
<EuiFormRow
Expand All @@ -126,13 +164,13 @@ export class EuiAbsoluteTab extends Component<
isInvalid={isTextInvalid}
value={textInputValue}
onChange={this.handleTextChange}
data-test-subj={'superDatePickerAbsoluteDateInput'}
data-test-subj="superDatePickerAbsoluteDateInput"
prepend={<EuiFormLabel>{labelPrefix}</EuiFormLabel>}
/>
</EuiFormRow>
)}
</EuiI18n>
</div>
</>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface EuiDatePopoverButtonProps {
isOpen: boolean;
needsUpdating?: boolean;
locale?: LocaleSpecifier;
onChange: NonNullable<EuiDatePopoverContentProps['onChange']>;
onChange: EuiDatePopoverContentProps['onChange'];
onPopoverClose: EuiPopoverProps['closePopover'];
onPopoverToggle: MouseEventHandler<HTMLButtonElement>;
position: 'start' | 'end';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { LocaleSpecifier } from 'moment'; // eslint-disable-line import/named

export interface EuiDatePopoverContentProps {
value: string;
onChange(date: string | null, event?: React.SyntheticEvent<any>): void;
onChange: (date: string) => void;
roundUp?: boolean;
dateFormat: string;
timeFormat: string;
Expand Down
14 changes: 14 additions & 0 deletions src/components/date_picker/super_date_picker/super_date_picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type EuiSuperDatePickerProps = CommonProps & {

/**
* Specifies the formatted used when displaying dates and/or datetimes
* @default 'MMM D, YYYY @ HH:mm:ss.SSS'
*/
dateFormat?: string;

Expand All @@ -92,13 +93,17 @@ export type EuiSuperDatePickerProps = CommonProps & {
isDisabled?: boolean | { display: ReactNode };

isLoading?: boolean;
/**
* @default true
*/
isPaused?: boolean;

/**
* Sets the overall width by adding sensible min and max widths.
* - `auto`: fits width to internal content / time string.
* - `restricted`: static width that fits the longest possible time string.
* - `full`: expands to 100% of the container.
* @default 'restricted'
*/
width?: 'restricted' | 'full' | 'auto';

Expand Down Expand Up @@ -139,20 +144,29 @@ export type EuiSuperDatePickerProps = CommonProps & {

/**
* Refresh interval in milliseconds
* @default 1000
*/
refreshInterval?: Milliseconds;

/**
* @default 'now-15m'
*/
start?: ShortDate;
/**
* @default 'now'
*/
end?: ShortDate;

/**
* Specifies the formatted used when displaying times
* @default 'HH:mm'
*/
timeFormat?: string;
utcOffset?: number;

/**
* Set showUpdateButton to false to immediately invoke onTimeChange for all start and end changes.
* @default true
*/
showUpdateButton?: boolean | 'iconOnly';

Expand Down
1 change: 1 addition & 0 deletions upcoming_changelogs/7331.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- For greater flexibility, `EuiSuperDatePicker` now allows users to paste ISO 8601, RFC 2822, and Unix timestamps in the `Absolute` tab input, in addition to timestamps in the `dateFormat` prop

0 comments on commit 3e26532

Please sign in to comment.