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[]) => ( +
+ {one} {two} +
+ )} +
+ ); + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/components/i18n/i18n.tsx b/src/components/i18n/i18n.tsx index 9a2c988badf..2659f36aa8d 100644 --- a/src/components/i18n/i18n.tsx +++ b/src/components/i18n/i18n.tsx @@ -98,6 +98,7 @@ interface I18nTokensShape { tokens: string[]; defaults: T; children: (x: Array) => ReactChild; + values?: Record; } export type EuiI18nProps< @@ -134,6 +135,7 @@ const EuiI18n = < i18nMapping: mapping, i18nMappingFunc: mappingFunc, valueDefault: props.defaults[idx], + values: props.values, render, }) ) diff --git a/upcoming_changelogs/7341.md b/upcoming_changelogs/7341.md new file mode 100644 index 00000000000..9f6d931b94d --- /dev/null +++ b/upcoming_changelogs/7341.md @@ -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`