From 0fc3c43acf029d6f58a146ae966063150c4fbd50 Mon Sep 17 00:00:00 2001
From: Cee Chen <549407+cee-chen@users.noreply.github.com>
Date: Mon, 6 Nov 2023 11:13:51 -0800
Subject: [PATCH] [EuiSuperDatePicker] Improve `Absolute` tab input UX further
(#7341)
---
.../date_popover/_absolute_tab.scss | 21 ++++++
.../date_popover/absolute_tab.test.tsx | 67 +++++++++++++++++--
.../date_popover/absolute_tab.tsx | 65 +++++++++++++-----
.../i18n/__snapshots__/i18n.test.tsx.snap | 30 ++++++++-
src/components/i18n/i18n.test.tsx | 22 +++++-
src/components/i18n/i18n.tsx | 2 +
upcoming_changelogs/7341.md | 2 +
7 files changed, 185 insertions(+), 24 deletions(-)
create mode 100644 upcoming_changelogs/7341.md
diff --git a/src/components/date_picker/super_date_picker/date_popover/_absolute_tab.scss b/src/components/date_picker/super_date_picker/date_popover/_absolute_tab.scss
index 59b70c9fdba..58db086692a 100644
--- a/src/components/date_picker/super_date_picker/date_popover/_absolute_tab.scss
+++ b/src/components/date_picker/super_date_picker/date_popover/_absolute_tab.scss
@@ -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;
+ }
}
diff --git a/src/components/date_picker/super_date_picker/date_popover/absolute_tab.test.tsx b/src/components/date_picker/super_date_picker/date_popover/absolute_tab.test.tsx
index 9b061b15702..517212930a6 100644
--- a/src/components/date_picker/super_date_picker/date_popover/absolute_tab.test.tsx
+++ b/src/components/date_picker/super_date_picker/date_popover/absolute_tab.test.tsx
@@ -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';
@@ -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',
@@ -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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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', () => {
diff --git a/src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx b/src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx
index 9ef766f77de..8eae41674e0 100644
--- a/src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx
+++ b/src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx
@@ -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';
@@ -40,6 +41,7 @@ export interface EuiAbsoluteTabProps {
}
interface EuiAbsoluteTabState {
+ hasUnparsedText: boolean;
isTextInvalid: boolean;
textInputValue: string;
valueAsMoment: Moment | null;
@@ -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);
@@ -63,6 +66,7 @@ export class EuiAbsoluteTab extends Component<
.format(this.props.dateFormat);
this.state = {
+ hasUnparsedText: false,
isTextInvalid: false,
textInputValue,
valueAsMoment,
@@ -80,27 +84,31 @@ export class EuiAbsoluteTab extends Component<
this.setState({
valueAsMoment,
textInputValue: valueAsMoment.format(this.props.dateFormat),
+ hasUnparsedText: false,
isTextInvalid: false,
});
};
- debouncedTypeTimeout: ReturnType | undefined;
-
handleTextChange = (event: ChangeEvent) => {
- 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,
};
@@ -108,6 +116,8 @@ export class EuiAbsoluteTab extends Component<
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();
@@ -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);
@@ -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 (
<>
@@ -149,21 +161,42 @@ export class EuiAbsoluteTab extends Component<
utcOffset={utcOffset}
/>
{dateFormat} }}
>
- {(dateFormatError: string) => (
+ {([dateFormatHint, dateFormatError]: string[]) => (
{
+ this.parseUserDateInput(event.clipboardData.getData('text'));
+ }}
+ onKeyDown={(event) => {
+ if (event.key === keys.ENTER) {
+ this.parseUserDateInput(textInputValue);
+ }
+ }}
data-test-subj="superDatePickerAbsoluteDateInput"
prepend={{labelPrefix}}
/>
diff --git a/src/components/i18n/__snapshots__/i18n.test.tsx.snap b/src/components/i18n/__snapshots__/i18n.test.tsx.snap
index 45c85c487b7..14b6a512e2d 100644
--- a/src/components/i18n/__snapshots__/i18n.test.tsx.snap
+++ b/src/components/i18n/__snapshots__/i18n.test.tsx.snap
@@ -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`] = `
`;
+exports[`EuiI18n default rendering render prop with multiple tokens renders strings with placeholders to the dom 1`] = `
+
+
+ This is the first basic string.
+
+ This is the a second string with a value.
+
+
+`;
+
exports[`EuiI18n default rendering render prop with single token calls a function and renders render prop result to the dom 1`] = `
{
});
describe('render prop with multiple tokens', () => {
- it('renders render prop result to the dom', () => {
+ it('renders basic strings to the dom', () => {
const component = mount(
{
);
expect(component).toMatchSnapshot();
});
+
+ it('renders strings with placeholders to the dom', () => {
+ const component = mount(
+
+ {([one, two]: ReactChild[]) => (
+