Skip to content

Commit

Permalink
[EuiSuperDatePicker] Improve Absolute tab input UX further (#7341)
Browse files Browse the repository at this point in the history
  • Loading branch information
cee-chen authored Nov 6, 2023
1 parent 92cef86 commit 0fc3c43
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
.euiSuperDatePicker__absoluteDateFormRow {
padding: 0 $euiSizeS $euiSizeS;

/* A bit of a visual trickery to make the format "hint" become an "error" text.
NOTE: Normally reordering visually (vs DOM) isn't super great for screen reader users,
but as the help text is already read out via `aria-describedby`, and the error text
is read out immediately via `aria-live`, we can fairly safely prioritize visuals instead */
.euiFormRow__fieldWrapper {
display: flex;
flex-direction: column;
};

.euiFormControlLayout {
order: 0;
}

.euiFormHelpText {
order: 1;
}

.euiFormErrorText {
order: 2;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import React from 'react';
import { act, fireEvent } from '@testing-library/react';
import { fireEvent } from '@testing-library/react';
import { render } from '../../../../test/rtl';

import { EuiAbsoluteTab } from './absolute_tab';
Expand All @@ -18,6 +18,12 @@ jest.mock('../../date_picker', () => ({
}));

describe('EuiAbsoluteTab', () => {
// mock requestAnimationFrame to fire immediately
const rafSpy = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: Function) => cb());
afterAll(() => rafSpy.mockRestore());

const props = {
dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS',
timeFormat: 'HH:mm',
Expand All @@ -29,14 +35,63 @@ describe('EuiAbsoluteTab', () => {
};

describe('user input', () => {
beforeAll(() => jest.useFakeTimers());
afterAll(() => jest.useRealTimers());
it('displays the enter key help text when the input has been edited and the date has not yet been parsed', () => {
const { getByTestSubject, queryByText } = render(
<EuiAbsoluteTab {...props} />
);
const helpText = 'Press the Enter key to parse as a date.';
expect(queryByText(helpText)).not.toBeInTheDocument();

const input = getByTestSubject('superDatePickerAbsoluteDateInput');
fireEvent.change(input, { target: { value: 'test' } });

expect(queryByText(helpText)).toBeInTheDocument();
});

it('displays the formats as a hint before parse, but as an error if invalid', () => {
const { getByTestSubject, queryByText } = render(
<EuiAbsoluteTab {...props} />
);
const formatHelpText = /Allowed formats: /;
expect(queryByText(formatHelpText)).not.toBeInTheDocument();

const input = getByTestSubject('superDatePickerAbsoluteDateInput');
fireEvent.change(input, { target: { value: 'test' } });
expect(queryByText(formatHelpText)).toHaveClass('euiFormHelpText');

fireEvent.keyDown(input, { key: 'Enter' });
expect(queryByText(formatHelpText)).toHaveClass('euiFormErrorText');
});

it('immediately parses pasted text without needing an extra enter keypress', () => {
const { getByTestSubject, queryByText } = render(
<EuiAbsoluteTab {...props} />
);
const input = getByTestSubject(
'superDatePickerAbsoluteDateInput'
) as HTMLInputElement;

fireEvent.paste(input, {
clipboardData: { getData: () => '1970-01-01' },
});
expect(input).not.toBeInvalid();
expect(input.value).toContain('Jan 1, 1970');

input.value = '';
fireEvent.paste(input, {
clipboardData: { getData: () => 'not a date' },
});
expect(input).toBeInvalid();

expect(queryByText(/Allowed formats: /)).toBeInTheDocument();
expect(queryByText(/Press the Enter key /)).not.toBeInTheDocument();
});
});

describe('date parsing', () => {
const changeInput = (input: HTMLElement, value: string) => {
fireEvent.change(input, { target: { value } });
act(() => {
jest.advanceTimersByTime(1000); // Debounce timer
});
fireEvent.keyDown(input, { key: 'Enter' });
};

it('parses the passed `dateFormat` prop', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import moment, { Moment, LocaleSpecifier } from 'moment'; // eslint-disable-line

import dateMath from '@elastic/datemath';

import { keys } from '../../../../services';
import { EuiFormRow, EuiFieldText, EuiFormLabel } from '../../../form';
import { EuiCode } from '../../../code';
import { EuiI18n } from '../../../i18n';
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface EuiAbsoluteTabProps {
}

interface EuiAbsoluteTabState {
hasUnparsedText: boolean;
isTextInvalid: boolean;
textInputValue: string;
valueAsMoment: Moment | null;
Expand All @@ -50,6 +52,7 @@ export class EuiAbsoluteTab extends Component<
EuiAbsoluteTabState
> {
state: EuiAbsoluteTabState;
isParsing = false; // Store outside of state as a ref for faster/unbatched updates

constructor(props: EuiAbsoluteTabProps) {
super(props);
Expand All @@ -63,6 +66,7 @@ export class EuiAbsoluteTab extends Component<
.format(this.props.dateFormat);

this.state = {
hasUnparsedText: false,
isTextInvalid: false,
textInputValue,
valueAsMoment,
Expand All @@ -80,34 +84,40 @@ export class EuiAbsoluteTab extends Component<
this.setState({
valueAsMoment,
textInputValue: valueAsMoment.format(this.props.dateFormat),
hasUnparsedText: false,
isTextInvalid: false,
});
};

debouncedTypeTimeout: ReturnType<typeof setTimeout> | undefined;

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

// 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
this.setState({
textInputValue: event.target.value,
hasUnparsedText: true,
isTextInvalid: false,
});
};

parseUserDateInput = () => {
const { onChange, dateFormat } = this.props;
const { textInputValue } = this.state;
parseUserDateInput = (textInputValue: string) => {
this.isParsing = true;
// Wait a tick for state to finish updating (whatever gets returned),
// and then allow `onChange` user input to continue setting state
requestAnimationFrame(() => {
this.isParsing = false;
});

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

const { onChange, dateFormat } = this.props;

// Attempt to parse with passed `dateFormat`
let valueAsMoment = moment(textInputValue, dateFormat, true);
let dateIsValid = valueAsMoment.isValid();
Expand All @@ -122,8 +132,9 @@ export class EuiAbsoluteTab extends Component<
onChange(valueAsMoment.toISOString());
this.setState({
textInputValue: valueAsMoment.format(this.props.dateFormat),
isTextInvalid: false,
valueAsMoment: valueAsMoment,
hasUnparsedText: false,
isTextInvalid: false,
});
} else {
this.setState(invalidDateState);
Expand All @@ -133,7 +144,8 @@ export class EuiAbsoluteTab extends Component<
render() {
const { dateFormat, timeFormat, locale, utcOffset, labelPrefix } =
this.props;
const { valueAsMoment, isTextInvalid, textInputValue } = this.state;
const { valueAsMoment, isTextInvalid, hasUnparsedText, textInputValue } =
this.state;

return (
<>
Expand All @@ -149,21 +161,42 @@ export class EuiAbsoluteTab extends Component<
utcOffset={utcOffset}
/>
<EuiI18n
token="euiAbsoluteTab.dateFormatError"
default="Allowed formats: {dateFormat}, ISO 8601, RFC 2822, or Unix timestamp"
tokens={[
'euiAbsoluteTab.dateFormatHint',
'euiAbsoluteTab.dateFormatError',
]}
defaults={[
'Press the Enter key to parse as a date.',
'Allowed formats: {dateFormat}, ISO 8601, RFC 2822, or Unix timestamp.',
]}
values={{ dateFormat: <EuiCode>{dateFormat}</EuiCode> }}
>
{(dateFormatError: string) => (
{([dateFormatHint, dateFormatError]: string[]) => (
<EuiFormRow
className="euiSuperDatePicker__absoluteDateFormRow"
isInvalid={isTextInvalid}
error={isTextInvalid ? dateFormatError : undefined}
helpText={
hasUnparsedText
? isTextInvalid
? dateFormatHint
: [dateFormatHint, dateFormatError]
: undefined
}
>
<EuiFieldText
compressed
isInvalid={isTextInvalid}
value={textInputValue}
onChange={this.handleTextChange}
onPaste={(event) => {
this.parseUserDateInput(event.clipboardData.getData('text'));
}}
onKeyDown={(event) => {
if (event.key === keys.ENTER) {
this.parseUserDateInput(textInputValue);
}
}}
data-test-subj="superDatePickerAbsoluteDateInput"
prepend={<EuiFormLabel>{labelPrefix}</EuiFormLabel>}
/>
Expand Down
30 changes: 29 additions & 1 deletion src/components/i18n/__snapshots__/i18n.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`EuiI18n default rendering render prop with multiple tokens renders render prop result to the dom 1`] = `
exports[`EuiI18n default rendering render prop with multiple tokens renders basic strings to the dom 1`] = `
<EuiI18n
defaults={
Array [
Expand All @@ -23,6 +23,34 @@ exports[`EuiI18n default rendering render prop with multiple tokens renders rend
</EuiI18n>
`;

exports[`EuiI18n default rendering render prop with multiple tokens renders strings with placeholders to the dom 1`] = `
<EuiI18n
defaults={
Array [
"This is the first basic string.",
"This is the a second string with a {placeholder}.",
]
}
tokens={
Array [
"test1",
"test2",
]
}
values={
Object {
"placeholder": "value",
}
}
>
<div>
This is the first basic string.
This is the a second string with a value.
</div>
</EuiI18n>
`;

exports[`EuiI18n default rendering render prop with single token calls a function and renders render prop result to the dom 1`] = `
<EuiI18n
default={
Expand Down
22 changes: 21 additions & 1 deletion src/components/i18n/i18n.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe('EuiI18n', () => {
});

describe('render prop with multiple tokens', () => {
it('renders render prop result to the dom', () => {
it('renders basic strings to the dom', () => {
const component = mount(
<EuiI18n
tokens={['test1', 'test2']}
Expand All @@ -117,6 +117,26 @@ describe('EuiI18n', () => {
);
expect(component).toMatchSnapshot();
});

it('renders strings with placeholders to the dom', () => {
const component = mount(
<EuiI18n
tokens={['test1', 'test2']}
defaults={[
'This is the first basic string.',
'This is the a second string with a {placeholder}.',
]}
values={{ placeholder: 'value' }}
>
{([one, two]: ReactChild[]) => (
<div>
{one} {two}
</div>
)}
</EuiI18n>
);
expect(component).toMatchSnapshot();
});
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/components/i18n/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ interface I18nTokensShape<T extends any[]> {
tokens: string[];
defaults: T;
children: (x: Array<T[number]>) => ReactChild;
values?: Record<string, ReactChild>;
}

export type EuiI18nProps<
Expand Down Expand Up @@ -134,6 +135,7 @@ const EuiI18n = <
i18nMapping: mapping,
i18nMappingFunc: mappingFunc,
valueDefault: props.defaults[idx],
values: props.values,
render,
})
)
Expand Down
2 changes: 2 additions & 0 deletions upcoming_changelogs/7341.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Improved the UX of `EuiSuperDatePicker`'s Absolute tab for users manually typing in timestamps
- Updated `Eui18n`s with multiple `tokens` to accept dynamic `values`

0 comments on commit 0fc3c43

Please sign in to comment.