From 5308e6885e0a8e3e9549fad7d18bad0b9ccd41ef Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 14 Sep 2022 13:43:01 -0600 Subject: [PATCH 01/19] WIP --- packages/components/src/DateInput.tsx | 143 +++++++++++++++++ packages/components/src/DateTimeInput.tsx | 179 ++++++++++++++++++++++ packages/components/src/MaskedInput.tsx | 9 +- packages/components/src/index.ts | 2 + 4 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/DateInput.tsx create mode 100644 packages/components/src/DateTimeInput.tsx diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx new file mode 100644 index 0000000000..17df67b688 --- /dev/null +++ b/packages/components/src/DateInput.tsx @@ -0,0 +1,143 @@ +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import Log from '@deephaven/log'; +import type { SelectionSegment } from './MaskedInput'; +import MaskedInput, { + DEFAULT_GET_PREFERRED_REPLACEMENT_STRING, +} from './MaskedInput'; + +const log = Log.module('DateInput'); + +const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; +const DEFAULT_VALUE_STRING = '2022-12-31'; +const FIXED_WIDTH_SPACE = '\u2007'; +const EMPTY_VALUE_STRING = DEFAULT_VALUE_STRING.replace( + /[a-zA-Z0-9]/g, + FIXED_WIDTH_SPACE +); +const EXAMPLES = [DEFAULT_VALUE_STRING]; +const DATE_FORMAT = 'yyyy-MM-dd'; + +type DateInputProps = { + className?: string; + onChange?(date?: string): void; + defaultValue?: string; + onFocus?(): void; + onBlur?(): void; + optional?: boolean; + 'data-testid'?: string; +}; + +// Forward ref causes a false positive for display-name in eslint: +// https://github.com/yannickcr/eslint-plugin-react/issues/2269 +// eslint-disable-next-line react/display-name +const DateInput = React.forwardRef( + (props: DateInputProps, ref) => { + const { + className = '', + onChange = () => false, + defaultValue = undefined, + onFocus = () => false, + onBlur = () => false, + optional = false, + 'data-testid': dataTestId, + } = props; + const [value, setValue] = useState(defaultValue ?? EMPTY_VALUE_STRING); + const [selection, setSelection] = useState(); + + function getNextNumberSegmentValue( + delta: number, + segmentValue: string, + lowerBound: number, + upperBound: number, + length: number + ) { + const modValue = upperBound - lowerBound + 1; + const newSegmentValue = + ((((parseInt(segmentValue, 10) - delta - lowerBound) % modValue) + + modValue) % + modValue) + + lowerBound; + return `${newSegmentValue}`.padStart(length, '0'); + } + + function getNextSegmentValue( + range: SelectionSegment, + delta: number, + segmentValue: string + ): string { + const { selectionStart } = range; + if (selectionStart === 0) { + return getNextNumberSegmentValue(delta, segmentValue, 1900, 2099, 4); + } + if (selectionStart === 5) { + return getNextNumberSegmentValue(delta, segmentValue, 1, 12, 2); + } + if (selectionStart === 8) { + return getNextNumberSegmentValue(delta, segmentValue, 1, 31, 2); + } + return ''; + } + + const handleBlur = useCallback( + e => { + // Don't trigger onBlur if the focus stays within the same parent + // i.e. changing focus between the input and the optional switch + if (e.target?.parentElement !== e.relatedTarget?.parentElement) { + onBlur(); + } + }, + [onBlur] + ); + + const handleChange = useCallback( + (newValue: string): void => { + log.debug('handleChange', newValue); + setValue(newValue); + onChange(newValue); + }, + [onChange] + ); + + function handleSelect(newSelection: SelectionSegment) { + setSelection(newSelection); + } + + return ( +
+ +
+ ); + } +); + +DateInput.defaultProps = { + className: '', + onChange: () => false, + defaultValue: undefined, + onFocus: () => false, + onBlur: () => false, + optional: false, + 'data-testid': undefined, +}; + +export default DateInput; diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx new file mode 100644 index 0000000000..f79bb155fb --- /dev/null +++ b/packages/components/src/DateTimeInput.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import Log from '@deephaven/log'; +import type { SelectionSegment } from './MaskedInput'; +import MaskedInput, { + DEFAULT_GET_PREFERRED_REPLACEMENT_STRING, +} from './MaskedInput'; + +const log = Log.module('DateTimeInput'); + +// This could be more restrictive and restrict days to the number of days in the month... +// But then gotta take leap year into account and everything. +const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; +// Put zero width spaces in the nanosecond part of the date to allow jumping between segments +const TIME_PATTERN = + '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}\u200B[0-9]{3}\u200B[0-9]{3}'; +const FULL_DATE_PATTERN = `${DATE_PATTERN} ${TIME_PATTERN}`; +const DEFAULT_VALUE_STRING = '2022-12-31 00:00:00.000000000'; +const FIXED_WIDTH_SPACE = '\u2007'; +const EMPTY_VALUE_STRING = DEFAULT_VALUE_STRING.replace( + /[a-zA-Z0-9]/g, + FIXED_WIDTH_SPACE +); +const FULL_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss.SSSSSSSSS'; + +type DateTimeInputProps = { + className?: string; + onChange?(value?: string): void; + defaultValue?: string; + onFocus?(): void; + onBlur?(): void; + optional?: boolean; + 'data-testid'?: string; +}; + +const addSeparators = (value: string) => + `${value.substring(0, 23)}\u200B${value.substring( + 23, + 26 + )}\u200B${value.substring(26)}`; + +const removeSeparators = (value: string) => value.replace(/\u200B/g, ''); + +const EXAMPLES = [addSeparators(DEFAULT_VALUE_STRING)]; + +// Forward ref causes a false positive for display-name in eslint: +// https://github.com/yannickcr/eslint-plugin-react/issues/2269 +// eslint-disable-next-line react/display-name +const DateTimeInput = React.forwardRef( + (props: DateTimeInputProps, ref) => { + const { + className = '', + onChange = () => false, + defaultValue = undefined, + onFocus = () => false, + onBlur = () => false, + optional = false, + 'data-testid': dataTestId, + } = props; + const [value, setValue] = useState( + addSeparators(defaultValue ?? EMPTY_VALUE_STRING) + ); + const [selection, setSelection] = useState(); + + function getNextNumberSegmentValue( + delta: number, + segmentValue: string, + lowerBound: number, + upperBound: number, + length: number + ) { + const modValue = upperBound - lowerBound + 1; + const newSegmentValue = + ((((parseInt(segmentValue, 10) - delta - lowerBound) % modValue) + + modValue) % + modValue) + + lowerBound; + const result = `${newSegmentValue}`.padStart(length, '0'); + log.debug('getNextNumberSegmentValue', modValue, newSegmentValue, result); + return result; + } + + function getNextSegmentValue( + range: SelectionSegment, + delta: number, + segmentValue: string + ): string { + const { selectionStart } = range; + if (selectionStart === 0) { + return getNextNumberSegmentValue(delta, segmentValue, 1900, 2099, 4); + } + if (selectionStart === 5) { + return getNextNumberSegmentValue(delta, segmentValue, 1, 12, 2); + } + if (selectionStart === 8) { + return getNextNumberSegmentValue(delta, segmentValue, 1, 31, 2); + } + if (selectionStart === 11) { + // Hours input + return getNextNumberSegmentValue(delta, segmentValue, 0, 23, 2); + } + if (selectionStart === 17 || selectionStart === 14) { + // Minutes/seconds input + return getNextNumberSegmentValue(delta, segmentValue, 0, 59, 2); + } + if ( + selectionStart === 20 || + selectionStart === 24 || + selectionStart === 28 + ) { + // Milli, micro, and nanosecond input + return getNextNumberSegmentValue(delta, segmentValue, 0, 999, 3); + } + + return segmentValue; + } + + const handleBlur = useCallback( + e => { + // Don't trigger onBlur if the focus stays within the same parent + // i.e. changing focus between the input and the optional switch + if (e.target?.parentElement !== e.relatedTarget?.parentElement) { + onBlur(); + } + }, + [onBlur] + ); + + const handleChange = useCallback( + (newValue: string): void => { + log.debug('handleChange', newValue); + setValue(newValue); + onChange(removeSeparators(newValue)); + }, + [onChange] + ); + + function handleSelect(newSelection: SelectionSegment) { + setSelection(newSelection); + } + + return ( +
+ +
+ ); + } +); + +DateTimeInput.defaultProps = { + className: '', + onChange: () => false, + defaultValue: undefined, + onFocus: () => false, + onBlur: () => false, + optional: false, + 'data-testid': undefined, +}; + +export default DateTimeInput; diff --git a/packages/components/src/MaskedInput.tsx b/packages/components/src/MaskedInput.tsx index 94519b3aed..908df0fd86 100644 --- a/packages/components/src/MaskedInput.tsx +++ b/packages/components/src/MaskedInput.tsx @@ -41,6 +41,8 @@ type MaskedInputProps = { className?: string; /** The regex pattern this masked input must match */ pattern: string; + /** Input placeholder */ + placeholder?: string; /** The current value to display */ value: string; /** One or more examples of valid values. Used when deciding if next keystroke is valid (as rest of the current value may be incomplete) */ @@ -67,7 +69,7 @@ type MaskedInputProps = { ): string; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; - + optional?: boolean; 'data-testid'?: string; }; @@ -87,7 +89,9 @@ const MaskedInput = React.forwardRef( getPreferredReplacementString = DEFAULT_GET_PREFERRED_REPLACEMENT_STRING, onChange = () => false, onSelect = () => false, + optional = false, pattern, + placeholder, selection, value, onFocus = () => false, @@ -484,6 +488,7 @@ const MaskedInput = React.forwardRef( className={classNames('form-control masked-input', className)} type="text" pattern={pattern} + placeholder={placeholder} value={value} onChange={() => undefined} onKeyDown={handleKeyDown} @@ -499,6 +504,7 @@ const MaskedInput = React.forwardRef( MaskedInput.defaultProps = { className: '', + placeholder: undefined, onChange(): void { // no-op }, @@ -514,6 +520,7 @@ MaskedInput.defaultProps = { onBlur(): void { // no-op }, + optional: false, 'data-testid': undefined, }; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 868774b145..735185f652 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -24,6 +24,8 @@ export { default as Collapse } from './Collapse'; export { default as Checkbox } from './Checkbox'; export { default as ComboBox } from './ComboBox'; export { default as CustomTimeSelect } from './CustomTimeSelect'; +export { default as DateTimeInput } from './DateTimeInput'; +export { default as DateInput } from './DateInput'; export { default as DebouncedSearchInput } from './DebouncedSearchInput'; export { default as DeephavenSpinner } from './DeephavenSpinner'; export { default as DraggableItemList } from './DraggableItemList'; From faa7750afdc56600f6ce746f27164b0e3f169b50 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 14 Sep 2022 22:02:25 -0600 Subject: [PATCH 02/19] Masked input updates and tests, Time, Date, DateTime inputs --- packages/components/src/DateInput.tsx | 16 ++---- packages/components/src/DateTimeInput.tsx | 13 ++--- packages/components/src/MaskedInput.test.tsx | 14 ++++- packages/components/src/MaskedInput.tsx | 55 ++++++++++++++++++-- packages/components/src/TimeInput.test.tsx | 37 +++++++++++++ 5 files changed, 107 insertions(+), 28 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 17df67b688..06f1e54885 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -10,11 +10,6 @@ const log = Log.module('DateInput'); const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; const DEFAULT_VALUE_STRING = '2022-12-31'; -const FIXED_WIDTH_SPACE = '\u2007'; -const EMPTY_VALUE_STRING = DEFAULT_VALUE_STRING.replace( - /[a-zA-Z0-9]/g, - FIXED_WIDTH_SPACE -); const EXAMPLES = [DEFAULT_VALUE_STRING]; const DATE_FORMAT = 'yyyy-MM-dd'; @@ -24,7 +19,6 @@ type DateInputProps = { defaultValue?: string; onFocus?(): void; onBlur?(): void; - optional?: boolean; 'data-testid'?: string; }; @@ -36,13 +30,12 @@ const DateInput = React.forwardRef( const { className = '', onChange = () => false, - defaultValue = undefined, + defaultValue = '', onFocus = () => false, onBlur = () => false, - optional = false, 'data-testid': dataTestId, } = props; - const [value, setValue] = useState(defaultValue ?? EMPTY_VALUE_STRING); + const [value, setValue] = useState(defaultValue); const [selection, setSelection] = useState(); function getNextNumberSegmentValue( @@ -121,8 +114,6 @@ const DateInput = React.forwardRef( value={value} onFocus={onFocus} onBlur={handleBlur} - // TODO: rename? - optional={optional} data-testid={dataTestId} /> @@ -133,10 +124,9 @@ const DateInput = React.forwardRef( DateInput.defaultProps = { className: '', onChange: () => false, - defaultValue: undefined, + defaultValue: '', onFocus: () => false, onBlur: () => false, - optional: false, 'data-testid': undefined, }; diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index f79bb155fb..d722082441 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -29,10 +29,10 @@ type DateTimeInputProps = { defaultValue?: string; onFocus?(): void; onBlur?(): void; - optional?: boolean; 'data-testid'?: string; }; +// TODO: only add separators when the string is sufficiently long const addSeparators = (value: string) => `${value.substring(0, 23)}\u200B${value.substring( 23, @@ -41,6 +41,7 @@ const addSeparators = (value: string) => const removeSeparators = (value: string) => value.replace(/\u200B/g, ''); +// TODO: test maskedInput with separators const EXAMPLES = [addSeparators(DEFAULT_VALUE_STRING)]; // Forward ref causes a false positive for display-name in eslint: @@ -51,14 +52,13 @@ const DateTimeInput = React.forwardRef( const { className = '', onChange = () => false, - defaultValue = undefined, + defaultValue = '', onFocus = () => false, onBlur = () => false, - optional = false, 'data-testid': dataTestId, } = props; const [value, setValue] = useState( - addSeparators(defaultValue ?? EMPTY_VALUE_STRING) + defaultValue.length > 0 ? addSeparators(defaultValue) : '' ); const [selection, setSelection] = useState(); @@ -158,8 +158,6 @@ const DateTimeInput = React.forwardRef( onFocus={onFocus} onBlur={handleBlur} data-testid={dataTestId} - // TODO: rename? - optional={optional} /> ); @@ -169,10 +167,9 @@ const DateTimeInput = React.forwardRef( DateTimeInput.defaultProps = { className: '', onChange: () => false, - defaultValue: undefined, + defaultValue: '', onFocus: () => false, onBlur: () => false, - optional: false, 'data-testid': undefined, }; diff --git a/packages/components/src/MaskedInput.test.tsx b/packages/components/src/MaskedInput.test.tsx index 8a5a2efd05..c936ff5c02 100644 --- a/packages/components/src/MaskedInput.test.tsx +++ b/packages/components/src/MaskedInput.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import MaskedInput from './MaskedInput'; +import MaskedInput, { fillToLength } from './MaskedInput'; const TIME_PATTERN = '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]'; @@ -15,5 +15,15 @@ function makeMaskedInput({ } it('mounts and unmounts properly', () => { - makeMaskedInput(); + const { unmount } = makeMaskedInput(); + unmount(); +}); + +describe('fillToLength', () => { + it('fills empty string with example value', () => { + expect(fillToLength('te', 'TEST', 0)).toBe('te'); + expect(fillToLength('te', 'TEST', 2)).toBe('te'); + expect(fillToLength('te', 'TEST', 4)).toBe('teST'); + expect(fillToLength('te', 'TEST', 10)).toBe('teST'); + }); }); diff --git a/packages/components/src/MaskedInput.tsx b/packages/components/src/MaskedInput.tsx index 908df0fd86..f950087af7 100644 --- a/packages/components/src/MaskedInput.tsx +++ b/packages/components/src/MaskedInput.tsx @@ -30,6 +30,23 @@ export function DEFAULT_GET_PREFERRED_REPLACEMENT_STRING( ); } +/** + * Pad the string on the left with the example value to the given length + * @param checkValue Initial string to pad + * @param exampleValue Example value + * @param length Target length + * @returns String padded with the given example value + */ +export function fillToLength( + checkValue: string, + exampleValue: string, + length: number +): string { + return checkValue.length < length + ? `${checkValue}${exampleValue.substring(checkValue.length, length)}` + : checkValue; +} + export type SelectionSegment = { selectionStart: number; selectionEnd: number; @@ -69,7 +86,6 @@ type MaskedInputProps = { ): string; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; - optional?: boolean; 'data-testid'?: string; }; @@ -89,7 +105,6 @@ const MaskedInput = React.forwardRef( getPreferredReplacementString = DEFAULT_GET_PREFERRED_REPLACEMENT_STRING, onChange = () => false, onSelect = () => false, - optional = false, pattern, placeholder, selection, @@ -378,6 +393,31 @@ const MaskedInput = React.forwardRef( event.preventDefault(); event.stopPropagation(); + // TODO: trim mask chars on the right before comparison + if (selectionEnd === value.length) { + const newValue = value.substring( + 0, + // Delete whole selection or the char before the cursor + selectionStart === selectionEnd + ? selectionStart - 1 + : selectionStart + ); + // TODO: The char before the cursor is one of the mask chars, delete the mask and the char before + // TODO: trim ALL mask chars and fixed spaces on the right after deletion + // if (selectionStart === selectionEnd && ) { + // // while mask ... selectionStart > 0 + // } + if (newValue !== value) { + onChange(newValue); + onSelect({ + selectionStart: newValue.length, + selectionEnd: newValue.length, + selectionDirection: SELECTION_DIRECTION.NONE, + }); + } + return; + } + if (selectionStart !== selectionEnd) { // Replace all non-masked characters with blanks, set selection to start const newValue = @@ -441,15 +481,21 @@ const MaskedInput = React.forwardRef( // If they're typing an alphanumeric character, be smart and allow it to jump ahead const maxReplaceIndex = /[a-zA-Z0-9]/g.test(newChar) - ? value.length - 1 + ? example[0].length - 1 : selectionStart; for ( let replaceIndex = selectionStart; replaceIndex <= maxReplaceIndex; replaceIndex += 1 ) { - const newValue = getPreferredReplacementString( + // Fill with example chars if necessary + const filledValue = fillToLength( value, + examples[0], + replaceIndex + 1 + ); + const newValue = getPreferredReplacementString( + filledValue, replaceIndex, newChar, selectionStart, @@ -520,7 +566,6 @@ MaskedInput.defaultProps = { onBlur(): void { // no-op }, - optional: false, 'data-testid': undefined, }; diff --git a/packages/components/src/TimeInput.test.tsx b/packages/components/src/TimeInput.test.tsx index dd44dc0d87..1e5b3c03d2 100644 --- a/packages/components/src/TimeInput.test.tsx +++ b/packages/components/src/TimeInput.test.tsx @@ -9,6 +9,8 @@ type SelectionDirection = SelectionSegment['selectionDirection']; const DEFAULT_VALUE = TimeUtils.parseTime('12:34:56'); +const FIXED_WIDTH_SPACE = '\u2007'; + function makeTimeInput({ value = DEFAULT_VALUE, onChange = jest.fn() } = {}) { return render(); } @@ -149,6 +151,41 @@ describe('select and type', () => { testSelectAndType(1, '000000', '00:00:00'); }); + it('handles backspace', () => { + // Replace selected section with fixed-width spaces + testSelectAndType( + 0, + '{backspace}', + `${FIXED_WIDTH_SPACE}${FIXED_WIDTH_SPACE}:34:56` + ); + testSelectAndType( + 3, + '{backspace}', + `12:${FIXED_WIDTH_SPACE}${FIXED_WIDTH_SPACE}:56` + ); + + // Allow deleting digits from the end + testSelectAndType(9, '{backspace}', `12:34:`); + + // Add missing mask chars + testSelectAndType(9, '{backspace}{backspace}1', `12:34:1`); + }); + + it('existing invalid behaviors that might need to be fixed', () => { + // Expected: '20:34:56'? + testSelectAndType(1, '5{arrowleft}2', `25:34:56`); + + // Fill in with zeros when skipping positions. Expected: '03:34:56' + testSelectAndType(0, '{backspace}3', `${FIXED_WIDTH_SPACE}3:34:56`); + + // Not sure it's ok to skip to the next section when the input isn't valid for the current section + // Expected: '03:34:56'? + testSelectAndType(0, '35', `03:54:56`); + + // Should validate whole value + // Expected: '03:54:11' + testSelectAndType(9, '11`"();', `12:34:11\`"();`); + }); }); describe('arrow left and right jumps segments', () => { From 7d17841edc0c0e58f93587f344819667333fcd0c Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 14 Sep 2022 22:41:21 -0600 Subject: [PATCH 03/19] Truncate empty mask on the right --- packages/components/src/DateInput.tsx | 3 +- packages/components/src/MaskedInput.test.tsx | 14 ++++++- packages/components/src/MaskedInput.tsx | 44 ++++++++++++++------ packages/components/src/TimeInput.test.tsx | 27 +++++++++++- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 06f1e54885..ac5ec3fe3f 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -9,8 +9,7 @@ import MaskedInput, { const log = Log.module('DateInput'); const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; -const DEFAULT_VALUE_STRING = '2022-12-31'; -const EXAMPLES = [DEFAULT_VALUE_STRING]; +const EXAMPLES = ['2000-01-01', '2022-12-31']; const DATE_FORMAT = 'yyyy-MM-dd'; type DateInputProps = { diff --git a/packages/components/src/MaskedInput.test.tsx b/packages/components/src/MaskedInput.test.tsx index c936ff5c02..7eb701718f 100644 --- a/packages/components/src/MaskedInput.test.tsx +++ b/packages/components/src/MaskedInput.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import MaskedInput, { fillToLength } from './MaskedInput'; +import MaskedInput, { fillToLength, trimTrailingMask } from './MaskedInput'; const TIME_PATTERN = '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]'; @@ -27,3 +27,15 @@ describe('fillToLength', () => { expect(fillToLength('te', 'TEST', 10)).toBe('teST'); }); }); + +describe('trimTrailingMask', () => { + expect(trimTrailingMask('00:00:00', ' : : ')).toBe('00:00:00'); + expect(trimTrailingMask('00:00:00', ' : ')).toBe('00:00:00'); + expect(trimTrailingMask('00:00', ' : : ')).toBe('00:00'); + expect(trimTrailingMask('00:00: ', ' : : ')).toBe('00:00'); + expect(trimTrailingMask('0 : : ', ' : : ')).toBe('0'); + expect(trimTrailingMask(' : : ', ' : : ')).toBe(''); + expect(trimTrailingMask('', ' : : ')).toBe(''); + expect(trimTrailingMask('00:00:00', '')).toBe('00:00:00'); + expect(trimTrailingMask('', '')).toBe(''); +}); diff --git a/packages/components/src/MaskedInput.tsx b/packages/components/src/MaskedInput.tsx index f950087af7..2fbc25ccf2 100644 --- a/packages/components/src/MaskedInput.tsx +++ b/packages/components/src/MaskedInput.tsx @@ -31,7 +31,7 @@ export function DEFAULT_GET_PREFERRED_REPLACEMENT_STRING( } /** - * Pad the string on the left with the example value to the given length + * Fill the string on the right side with the example value to the given length * @param checkValue Initial string to pad * @param exampleValue Example value * @param length Target length @@ -47,6 +47,24 @@ export function fillToLength( : checkValue; } +/** + * Trim all characters matching the empty mask on the right side of the given value + * @param value String to trim + * @param emptyMask Empty mask + * @returns Trimmed string + */ +export function trimTrailingMask(value: string, emptyMask: string): string { + let { length } = value; + for (let i = value.length - 1; i >= 0; i -= 1) { + if (emptyMask[i] === value[i]) { + length = i; + } else { + break; + } + } + return value.substring(0, length); +} + export type SelectionSegment = { selectionStart: number; selectionEnd: number; @@ -118,6 +136,10 @@ const MaskedInput = React.forwardRef( () => (Array.isArray(example) ? example : [example]), [example] ); + const emptyMask = useMemo( + () => examples[0].replace(/[a-zA-Z0-9]/g, FIXED_WIDTH_SPACE), + [examples] + ); useEffect( function setSelectedSegment() { @@ -393,25 +415,21 @@ const MaskedInput = React.forwardRef( event.preventDefault(); event.stopPropagation(); - // TODO: trim mask chars on the right before comparison - if (selectionEnd === value.length) { + // Deleting at the end of the value + if (selectionEnd >= trimTrailingMask(value, emptyMask).length) { const newValue = value.substring( 0, - // Delete whole selection or the char before the cursor + // Delete whole selection or just the char before the cursor selectionStart === selectionEnd ? selectionStart - 1 : selectionStart ); - // TODO: The char before the cursor is one of the mask chars, delete the mask and the char before - // TODO: trim ALL mask chars and fixed spaces on the right after deletion - // if (selectionStart === selectionEnd && ) { - // // while mask ... selectionStart > 0 - // } - if (newValue !== value) { - onChange(newValue); + const trimmedValue = trimTrailingMask(newValue, emptyMask); + if (trimmedValue !== value) { + onChange(trimmedValue); onSelect({ - selectionStart: newValue.length, - selectionEnd: newValue.length, + selectionStart: trimmedValue.length, + selectionEnd: trimmedValue.length, selectionDirection: SELECTION_DIRECTION.NONE, }); } diff --git a/packages/components/src/TimeInput.test.tsx b/packages/components/src/TimeInput.test.tsx index 1e5b3c03d2..daec31c63d 100644 --- a/packages/components/src/TimeInput.test.tsx +++ b/packages/components/src/TimeInput.test.tsx @@ -165,10 +165,33 @@ describe('select and type', () => { ); // Allow deleting digits from the end - testSelectAndType(9, '{backspace}', `12:34:`); + testSelectAndType(9, '{backspace}', `12:34`); // Add missing mask chars - testSelectAndType(9, '{backspace}{backspace}1', `12:34:1`); + testSelectAndType(9, '{backspace}{backspace}12', `12:31:2`); + }); + + it('trims trailing mask and spaces', () => { + const { unmount } = makeTimeInput(); + const input: HTMLInputElement = screen.getByRole('textbox'); + + input.setSelectionRange(3, 3); + + userEvent.type(input, '{backspace}'); + + input.setSelectionRange(9, 9); + + userEvent.type(input, '{backspace}'); + + expect(input.value).toEqual(`12`); + + input.setSelectionRange(1, 1); + + userEvent.type(input, '{backspace}'); + + expect(input.value).toEqual(``); + + unmount(); }); it('existing invalid behaviors that might need to be fixed', () => { From 67e863e1161e2736a3cc57e547aa414e4b33dda0 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 14 Sep 2022 22:46:47 -0600 Subject: [PATCH 04/19] Cleanup diff --- packages/components/src/MaskedInput.test.tsx | 22 +++++++++++--------- packages/components/src/MaskedInput.tsx | 5 +++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/components/src/MaskedInput.test.tsx b/packages/components/src/MaskedInput.test.tsx index 7eb701718f..b0172d19cf 100644 --- a/packages/components/src/MaskedInput.test.tsx +++ b/packages/components/src/MaskedInput.test.tsx @@ -20,7 +20,7 @@ it('mounts and unmounts properly', () => { }); describe('fillToLength', () => { - it('fills empty string with example value', () => { + it('fills empty string with the example value', () => { expect(fillToLength('te', 'TEST', 0)).toBe('te'); expect(fillToLength('te', 'TEST', 2)).toBe('te'); expect(fillToLength('te', 'TEST', 4)).toBe('teST'); @@ -29,13 +29,15 @@ describe('fillToLength', () => { }); describe('trimTrailingMask', () => { - expect(trimTrailingMask('00:00:00', ' : : ')).toBe('00:00:00'); - expect(trimTrailingMask('00:00:00', ' : ')).toBe('00:00:00'); - expect(trimTrailingMask('00:00', ' : : ')).toBe('00:00'); - expect(trimTrailingMask('00:00: ', ' : : ')).toBe('00:00'); - expect(trimTrailingMask('0 : : ', ' : : ')).toBe('0'); - expect(trimTrailingMask(' : : ', ' : : ')).toBe(''); - expect(trimTrailingMask('', ' : : ')).toBe(''); - expect(trimTrailingMask('00:00:00', '')).toBe('00:00:00'); - expect(trimTrailingMask('', '')).toBe(''); + it('trims characters matching the empty mask on the right', () => { + expect(trimTrailingMask('00:00:00', ' : : ')).toBe('00:00:00'); + expect(trimTrailingMask('00:00:00', ' : ')).toBe('00:00:00'); + expect(trimTrailingMask('00:00', ' : : ')).toBe('00:00'); + expect(trimTrailingMask('00:00: ', ' : : ')).toBe('00:00'); + expect(trimTrailingMask('0 : : ', ' : : ')).toBe('0'); + expect(trimTrailingMask(' : : ', ' : : ')).toBe(''); + expect(trimTrailingMask('', ' : : ')).toBe(''); + expect(trimTrailingMask('00:00:00', '')).toBe('00:00:00'); + expect(trimTrailingMask('', '')).toBe(''); + }); }); diff --git a/packages/components/src/MaskedInput.tsx b/packages/components/src/MaskedInput.tsx index 2fbc25ccf2..91636f6577 100644 --- a/packages/components/src/MaskedInput.tsx +++ b/packages/components/src/MaskedInput.tsx @@ -104,6 +104,7 @@ type MaskedInputProps = { ): string; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; + 'data-testid'?: string; }; @@ -499,14 +500,14 @@ const MaskedInput = React.forwardRef( // If they're typing an alphanumeric character, be smart and allow it to jump ahead const maxReplaceIndex = /[a-zA-Z0-9]/g.test(newChar) - ? example[0].length - 1 + ? examples[0].length - 1 : selectionStart; for ( let replaceIndex = selectionStart; replaceIndex <= maxReplaceIndex; replaceIndex += 1 ) { - // Fill with example chars if necessary + // Fill with the example chars if necessary const filledValue = fillToLength( value, examples[0], From 7c84049c36e6314135f12a1b1b1bdd8b2eb5fb0f Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 14 Sep 2022 23:30:52 -0600 Subject: [PATCH 05/19] Fix addSeparators, add unit tests --- .../components/src/DateTimeInput.test.tsx | 61 +++++++++++++++++++ packages/components/src/DateTimeInput.tsx | 20 +++--- packages/components/src/TimeInput.test.tsx | 2 +- 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 packages/components/src/DateTimeInput.test.tsx diff --git a/packages/components/src/DateTimeInput.test.tsx b/packages/components/src/DateTimeInput.test.tsx new file mode 100644 index 0000000000..e8f65259c9 --- /dev/null +++ b/packages/components/src/DateTimeInput.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import DateTimeInput, { addSeparators } from './DateTimeInput'; + +const DEFAULT_DATE_TIME = '2022-02-22 00:00:00.000000000'; +// Zero width space +const Z = '\u200B'; +// Fixed width space +const F = '\u2007'; + +function makeDateTimeInput({ + value = DEFAULT_DATE_TIME, + onChange = jest.fn(), +} = {}) { + return render(); +} + +it('mounts and unmounts properly', () => { + const { unmount } = makeDateTimeInput(); + unmount(); +}); + +it('trims trailing mask and spaces, strips zero-width spaces in onChange', () => { + const onChange = jest.fn(); + const { unmount } = makeDateTimeInput({ onChange }); + const input: HTMLInputElement = screen.getByRole('textbox'); + + input.setSelectionRange(22, 22); + userEvent.type(input, '{backspace}'); + input.setSelectionRange(25, 25); + userEvent.type(input, '{backspace}'); + expect(input.value).toEqual( + `2022-02-22 00:00:00.${F}${F}${F}${Z}${F}${F}${F}${Z}000` + ); + expect(onChange).toBeCalledWith( + `2022-02-22 00:00:00.${F}${F}${F}${F}${F}${F}000` + ); + + input.setSelectionRange(29, 29); + userEvent.type(input, '{backspace}'); + expect(input.value).toEqual(`2022-02-22 00:00:00`); + expect(onChange).toBeCalledWith(`2022-02-22 00:00:00`); + + unmount(); +}); + +describe('addSeparators', () => { + it('adds separators between nano/micro/milliseconds', () => { + expect(addSeparators(DEFAULT_DATE_TIME)).toBe( + `2022-02-22 00:00:00.000${Z}000${Z}000` + ); + }); + + it('adds only necessary separators', () => { + expect(addSeparators('2022-02-22 00:00:00.000000')).toBe( + `2022-02-22 00:00:00.000${Z}000` + ); + expect(addSeparators('2022-02-22')).toBe(`2022-02-22`); + }); +}); diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index d722082441..cf907d04b9 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -15,12 +15,7 @@ const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; const TIME_PATTERN = '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}\u200B[0-9]{3}\u200B[0-9]{3}'; const FULL_DATE_PATTERN = `${DATE_PATTERN} ${TIME_PATTERN}`; -const DEFAULT_VALUE_STRING = '2022-12-31 00:00:00.000000000'; -const FIXED_WIDTH_SPACE = '\u2007'; -const EMPTY_VALUE_STRING = DEFAULT_VALUE_STRING.replace( - /[a-zA-Z0-9]/g, - FIXED_WIDTH_SPACE -); +const DEFAULT_VALUE_STRING = '2022-01-01 00:00:00.000000000'; const FULL_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss.SSSSSSSSS'; type DateTimeInputProps = { @@ -32,16 +27,15 @@ type DateTimeInputProps = { 'data-testid'?: string; }; -// TODO: only add separators when the string is sufficiently long -const addSeparators = (value: string) => - `${value.substring(0, 23)}\u200B${value.substring( - 23, - 26 - )}\u200B${value.substring(26)}`; +export function addSeparators(value: string): string { + const dateTimeMillis = value.substring(0, 23); + const micros = value.substring(23, 26); + const nanos = value.substring(26); + return [dateTimeMillis, micros, nanos].filter(v => v !== '').join('\u200B'); +} const removeSeparators = (value: string) => value.replace(/\u200B/g, ''); -// TODO: test maskedInput with separators const EXAMPLES = [addSeparators(DEFAULT_VALUE_STRING)]; // Forward ref causes a false positive for display-name in eslint: diff --git a/packages/components/src/TimeInput.test.tsx b/packages/components/src/TimeInput.test.tsx index daec31c63d..07196746f0 100644 --- a/packages/components/src/TimeInput.test.tsx +++ b/packages/components/src/TimeInput.test.tsx @@ -206,7 +206,7 @@ describe('select and type', () => { testSelectAndType(0, '35', `03:54:56`); // Should validate whole value - // Expected: '03:54:11' + // Expected: '12:34:11' testSelectAndType(9, '11`"();', `12:34:11\`"();`); }); }); From 7ce0369b80192b32ee64488025c88667c6d4c7b0 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Thu, 15 Sep 2022 08:16:20 -0600 Subject: [PATCH 06/19] Delete old DateInput dependent on jsapi-shim, update Styleguide --- .../code-studio/src/components/DateInput.jsx | 127 ------------------ .../code-studio/src/styleguide/Inputs.tsx | 7 +- 2 files changed, 5 insertions(+), 129 deletions(-) delete mode 100644 packages/code-studio/src/components/DateInput.jsx diff --git a/packages/code-studio/src/components/DateInput.jsx b/packages/code-studio/src/components/DateInput.jsx deleted file mode 100644 index f2bdcdb075..0000000000 --- a/packages/code-studio/src/components/DateInput.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import dh from '@deephaven/jsapi-shim'; -import Log from '@deephaven/log'; -import { MaskedInput } from '@deephaven/components'; - -const log = Log.module('DateInput'); - -// This could be more restrictive and restrict days to the number of days in the month... -// But then gotta take leap year into account and everything. -const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; -// Put zero width spaces in the nanosecond part of the date to allow jumping between segments -const TIME_PATTERN = - '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}\u200B[0-9]{3}\u200B[0-9]{3}'; -const FULL_DATE_PATTERN = `${DATE_PATTERN} ${TIME_PATTERN}`; -const DH_FORMAT_PATTERN = 'yyyy-MM-dd HH:mm:ss.SSSSSSSSS'; -const DEFAULT_VALUE_STRING = '2019-01-01 00:00:00.000\u200B000\u200B000'; -const EXAMPLES = [DEFAULT_VALUE_STRING]; - -const parseDateString = dateString => - dh.i18n.DateTimeFormat.parse( - DH_FORMAT_PATTERN, - dateString.replace(/\u200B/g, '') - ); - -const formatDateAsString = date => { - const formattedString = dh.i18n.DateTimeFormat.format( - DH_FORMAT_PATTERN, - date - ); - - // Add the zero width spaces to separate milli/micro/nano - return `${formattedString.substring(0, 23)}\u200B${formattedString.substring( - 23, - 26 - )}\u200B${formattedString.substring(26)}`; -}; - -const DateInput = props => { - const { className, defaultValue, onChange } = props; - const [value, setValue] = useState(formatDateAsString(defaultValue)); - const [selection, setSelection] = useState(null); - - function getNextNumberSegmentValue( - delta, - segmentValue, - lowerBound, - upperBound, - length - ) { - const modValue = upperBound - lowerBound + 1; - const newSegmentValue = - ((((parseInt(segmentValue, 10) - delta - lowerBound) % modValue) + - modValue) % - modValue) + - lowerBound; - return `${newSegmentValue}`.padStart(length, '0'); - } - - function getNextSegmentValue(range, delta, segmentValue) { - const { selectionStart } = range; - if (selectionStart === 0) { - return getNextNumberSegmentValue(delta, segmentValue, 1900, 2099, 4); - } - if (selectionStart === 5) { - return getNextNumberSegmentValue(delta, segmentValue, 1, 12, 2); - } - if (selectionStart === 8) { - return getNextNumberSegmentValue(delta, segmentValue, 1, 31, 2); - } - if (selectionStart === 11) { - // Hours input - return getNextNumberSegmentValue(delta, segmentValue, 0, 23, 2); - } - if (selectionStart === 17 || selectionStart === 14) { - // Minutes/seconds input - return getNextNumberSegmentValue(delta, segmentValue, 0, 59, 2); - } - if ( - selectionStart === 20 || - selectionStart === 24 || - selectionStart === 28 - ) { - // Milli, micro, and nanosecond input - return getNextNumberSegmentValue(delta, segmentValue, 0, 999, 3); - } - - return segmentValue; - } - - function handleChange(newValue) { - log.debug('handleChange', newValue); - setValue(newValue); - onChange(newValue); - } - - function handleSelect(newSelection) { - setSelection(newSelection); - } - - return ( - - ); -}; - -DateInput.propTypes = { - className: PropTypes.string, - defaultValue: PropTypes.shape({}), - onChange: PropTypes.func, -}; - -DateInput.defaultProps = { - className: '', - defaultValue: parseDateString(DEFAULT_VALUE_STRING), - onChange: () => {}, -}; - -export default DateInput; diff --git a/packages/code-studio/src/styleguide/Inputs.tsx b/packages/code-studio/src/styleguide/Inputs.tsx index 0a2a78ffdb..b34af00470 100644 --- a/packages/code-studio/src/styleguide/Inputs.tsx +++ b/packages/code-studio/src/styleguide/Inputs.tsx @@ -8,10 +8,11 @@ import { RadioGroup, SearchInput, TimeInput, + DateInput, + DateTimeInput, CustomTimeSelect, UISwitch, } from '@deephaven/components'; -import DateInput from '../components/DateInput'; interface InputsState { on: boolean; @@ -318,7 +319,9 @@ class Inputs extends Component, InputsState> {
Date Input
- Does not work in mock +
+
DateTime Input
+
Custom Timeselect
Date: Thu, 15 Sep 2022 08:20:24 -0600 Subject: [PATCH 07/19] Cleanup --- packages/components/src/DateInput.tsx | 13 +------------ packages/components/src/DateTimeInput.tsx | 13 +------------ 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index ac5ec3fe3f..82895ec096 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -71,17 +71,6 @@ const DateInput = React.forwardRef( return ''; } - const handleBlur = useCallback( - e => { - // Don't trigger onBlur if the focus stays within the same parent - // i.e. changing focus between the input and the optional switch - if (e.target?.parentElement !== e.relatedTarget?.parentElement) { - onBlur(); - } - }, - [onBlur] - ); - const handleChange = useCallback( (newValue: string): void => { log.debug('handleChange', newValue); @@ -112,7 +101,7 @@ const DateInput = React.forwardRef( selection={selection} value={value} onFocus={onFocus} - onBlur={handleBlur} + onBlur={onBlur} data-testid={dataTestId} /> diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index cf907d04b9..ebed773998 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -109,17 +109,6 @@ const DateTimeInput = React.forwardRef( return segmentValue; } - const handleBlur = useCallback( - e => { - // Don't trigger onBlur if the focus stays within the same parent - // i.e. changing focus between the input and the optional switch - if (e.target?.parentElement !== e.relatedTarget?.parentElement) { - onBlur(); - } - }, - [onBlur] - ); - const handleChange = useCallback( (newValue: string): void => { log.debug('handleChange', newValue); @@ -150,7 +139,7 @@ const DateTimeInput = React.forwardRef( selection={selection} value={value} onFocus={onFocus} - onBlur={handleBlur} + onBlur={onBlur} data-testid={dataTestId} /> From bbc23fa46eb7dfae9ba98a27e82452b9f078d47d Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Mon, 19 Sep 2022 07:10:32 -0600 Subject: [PATCH 08/19] Uppercase placeholders --- packages/components/src/DateInput.tsx | 2 +- packages/components/src/DateTimeInput.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 82895ec096..4688176571 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -10,7 +10,7 @@ const log = Log.module('DateInput'); const DATE_PATTERN = '[12][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])'; const EXAMPLES = ['2000-01-01', '2022-12-31']; -const DATE_FORMAT = 'yyyy-MM-dd'; +const DATE_FORMAT = 'YYYY-MM-DD'; type DateInputProps = { className?: string; diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index ebed773998..522afea03d 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -16,7 +16,7 @@ const TIME_PATTERN = '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}\u200B[0-9]{3}\u200B[0-9]{3}'; const FULL_DATE_PATTERN = `${DATE_PATTERN} ${TIME_PATTERN}`; const DEFAULT_VALUE_STRING = '2022-01-01 00:00:00.000000000'; -const FULL_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss.SSSSSSSSS'; +const FULL_DATE_FORMAT = 'YYYY-MM-DD HH:MM:SS.SSSSSSSSS'; type DateTimeInputProps = { className?: string; From 063880be03171ccafe783462721ae6499314eab4 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Tue, 20 Sep 2022 09:21:56 -0600 Subject: [PATCH 09/19] Update packages/components/src/DateInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateInput.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 4688176571..5b91aa1eed 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -91,9 +91,6 @@ const DateInput = React.forwardRef( className={classNames(className)} example={EXAMPLES} getNextSegmentValue={getNextSegmentValue} - getPreferredReplacementString={ - DEFAULT_GET_PREFERRED_REPLACEMENT_STRING - } onChange={handleChange} onSelect={handleSelect} pattern={DATE_PATTERN} From 1f3794f9c631038c208f8b07087d526494398684 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Tue, 20 Sep 2022 09:22:07 -0600 Subject: [PATCH 10/19] Update packages/components/src/DateInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 5b91aa1eed..f0d03022cc 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -28,10 +28,10 @@ const DateInput = React.forwardRef( (props: DateInputProps, ref) => { const { className = '', - onChange = () => false, + onChange = () => undefined, defaultValue = '', - onFocus = () => false, - onBlur = () => false, + onFocus = () => undefined, + onBlur = () => undefined, 'data-testid': dataTestId, } = props; const [value, setValue] = useState(defaultValue); From 86b5c0bdc6fd68ee0c6d9615e279e23006795435 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Tue, 20 Sep 2022 09:22:15 -0600 Subject: [PATCH 11/19] Update packages/components/src/DateInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateInput.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index f0d03022cc..4b3844ab23 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -106,6 +106,8 @@ const DateInput = React.forwardRef( } ); +DateInput.displayName = 'DateInput'; + DateInput.defaultProps = { className: '', onChange: () => false, From 511fa690a29d578a143b8f4abecd382cea05ed03 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Tue, 20 Sep 2022 09:22:29 -0600 Subject: [PATCH 12/19] Update packages/components/src/DateInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateInput.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 4b3844ab23..9de403e4db 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -21,9 +21,6 @@ type DateInputProps = { 'data-testid'?: string; }; -// Forward ref causes a false positive for display-name in eslint: -// https://github.com/yannickcr/eslint-plugin-react/issues/2269 -// eslint-disable-next-line react/display-name const DateInput = React.forwardRef( (props: DateInputProps, ref) => { const { From f39de6935049d17793cac9842d6dac48dc80d367 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 09:25:39 -0600 Subject: [PATCH 13/19] Update packages/components/src/DateTimeInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateTimeInput.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index 522afea03d..88163d1142 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -38,9 +38,6 @@ const removeSeparators = (value: string) => value.replace(/\u200B/g, ''); const EXAMPLES = [addSeparators(DEFAULT_VALUE_STRING)]; -// Forward ref causes a false positive for display-name in eslint: -// https://github.com/yannickcr/eslint-plugin-react/issues/2269 -// eslint-disable-next-line react/display-name const DateTimeInput = React.forwardRef( (props: DateTimeInputProps, ref) => { const { From d46e14bbba2d00fe0f2287fd5f9b8133d9af198e Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 09:25:50 -0600 Subject: [PATCH 14/19] Update packages/components/src/DateTimeInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateTimeInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index 88163d1142..f4d6f46893 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -42,10 +42,10 @@ const DateTimeInput = React.forwardRef( (props: DateTimeInputProps, ref) => { const { className = '', - onChange = () => false, + onChange = () => undefined, defaultValue = '', - onFocus = () => false, - onBlur = () => false, + onFocus = () => undefined, + onBlur = () => undefined, 'data-testid': dataTestId, } = props; const [value, setValue] = useState( From 584378263c3516f503a84962f03cfc74826036a7 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 09:26:13 -0600 Subject: [PATCH 15/19] Update packages/components/src/DateTimeInput.tsx Co-authored-by: Mike Bender --- packages/components/src/DateTimeInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index f4d6f46893..e802f2b719 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -146,10 +146,10 @@ const DateTimeInput = React.forwardRef( DateTimeInput.defaultProps = { className: '', - onChange: () => false, + onChange: () => undefined, defaultValue: '', - onFocus: () => false, - onBlur: () => false, + onFocus: () => undefined, + onBlur: () => undefined, 'data-testid': undefined, }; From 1eb8fd8e0299dc4fac83c732aec2e63daa2a8d2c Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 09:29:40 -0600 Subject: [PATCH 16/19] Fix review comments --- packages/components/src/DateInput.tsx | 4 +--- packages/components/src/DateTimeInput.tsx | 15 ++++----------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 9de403e4db..083c35c39a 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -2,9 +2,7 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import Log from '@deephaven/log'; import type { SelectionSegment } from './MaskedInput'; -import MaskedInput, { - DEFAULT_GET_PREFERRED_REPLACEMENT_STRING, -} from './MaskedInput'; +import MaskedInput from './MaskedInput'; const log = Log.module('DateInput'); diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index e802f2b719..eedfcc5e77 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -2,9 +2,7 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import Log from '@deephaven/log'; import type { SelectionSegment } from './MaskedInput'; -import MaskedInput, { - DEFAULT_GET_PREFERRED_REPLACEMENT_STRING, -} from './MaskedInput'; +import MaskedInput from './MaskedInput'; const log = Log.module('DateTimeInput'); @@ -115,10 +113,6 @@ const DateTimeInput = React.forwardRef( [onChange] ); - function handleSelect(newSelection: SelectionSegment) { - setSelection(newSelection); - } - return (
( className={classNames(className)} example={EXAMPLES} getNextSegmentValue={getNextSegmentValue} - getPreferredReplacementString={ - DEFAULT_GET_PREFERRED_REPLACEMENT_STRING - } onChange={handleChange} - onSelect={handleSelect} + onSelect={setSelection} pattern={FULL_DATE_PATTERN} placeholder={FULL_DATE_FORMAT} selection={selection} @@ -144,6 +135,8 @@ const DateTimeInput = React.forwardRef( } ); +DateTimeInput.displayName = 'DateTimeInput'; + DateTimeInput.defaultProps = { className: '', onChange: () => undefined, From 0cbf245403cb5112596a43bd9b02f94f8cd40d1c Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 09:38:11 -0600 Subject: [PATCH 17/19] Fix review comments, add DateInputUtils --- packages/components/src/DateInput.tsx | 44 ++--------------- packages/components/src/DateInputUtils.ts | 48 +++++++++++++++++++ packages/components/src/DateTimeInput.tsx | 57 +---------------------- 3 files changed, 53 insertions(+), 96 deletions(-) create mode 100644 packages/components/src/DateInputUtils.ts diff --git a/packages/components/src/DateInput.tsx b/packages/components/src/DateInput.tsx index 083c35c39a..ec5d4d4845 100644 --- a/packages/components/src/DateInput.tsx +++ b/packages/components/src/DateInput.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import Log from '@deephaven/log'; -import type { SelectionSegment } from './MaskedInput'; -import MaskedInput from './MaskedInput'; +import MaskedInput, { SelectionSegment } from './MaskedInput'; +import { getNextSegmentValue } from './DateInputUtils'; const log = Log.module('DateInput'); @@ -32,40 +32,6 @@ const DateInput = React.forwardRef( const [value, setValue] = useState(defaultValue); const [selection, setSelection] = useState(); - function getNextNumberSegmentValue( - delta: number, - segmentValue: string, - lowerBound: number, - upperBound: number, - length: number - ) { - const modValue = upperBound - lowerBound + 1; - const newSegmentValue = - ((((parseInt(segmentValue, 10) - delta - lowerBound) % modValue) + - modValue) % - modValue) + - lowerBound; - return `${newSegmentValue}`.padStart(length, '0'); - } - - function getNextSegmentValue( - range: SelectionSegment, - delta: number, - segmentValue: string - ): string { - const { selectionStart } = range; - if (selectionStart === 0) { - return getNextNumberSegmentValue(delta, segmentValue, 1900, 2099, 4); - } - if (selectionStart === 5) { - return getNextNumberSegmentValue(delta, segmentValue, 1, 12, 2); - } - if (selectionStart === 8) { - return getNextNumberSegmentValue(delta, segmentValue, 1, 31, 2); - } - return ''; - } - const handleChange = useCallback( (newValue: string): void => { log.debug('handleChange', newValue); @@ -75,10 +41,6 @@ const DateInput = React.forwardRef( [onChange] ); - function handleSelect(newSelection: SelectionSegment) { - setSelection(newSelection); - } - return (
( example={EXAMPLES} getNextSegmentValue={getNextSegmentValue} onChange={handleChange} - onSelect={handleSelect} + onSelect={setSelection} pattern={DATE_PATTERN} placeholder={DATE_FORMAT} selection={selection} diff --git a/packages/components/src/DateInputUtils.ts b/packages/components/src/DateInputUtils.ts new file mode 100644 index 0000000000..d941f3b425 --- /dev/null +++ b/packages/components/src/DateInputUtils.ts @@ -0,0 +1,48 @@ +import { SelectionSegment } from './MaskedInput'; + +export function getNextNumberSegmentValue( + delta: number, + segmentValue: string, + lowerBound: number, + upperBound: number, + length: number +): string { + const modValue = upperBound - lowerBound + 1; + const newSegmentValue = + ((((parseInt(segmentValue, 10) - delta - lowerBound) % modValue) + + modValue) % + modValue) + + lowerBound; + return `${newSegmentValue}`.padStart(length, '0'); +} + +export function getNextSegmentValue( + range: SelectionSegment, + delta: number, + segmentValue: string +): string { + const { selectionStart } = range; + if (selectionStart === 0) { + return getNextNumberSegmentValue(delta, segmentValue, 1900, 2099, 4); + } + if (selectionStart === 5) { + return getNextNumberSegmentValue(delta, segmentValue, 1, 12, 2); + } + if (selectionStart === 8) { + return getNextNumberSegmentValue(delta, segmentValue, 1, 31, 2); + } + if (selectionStart === 11) { + // Hours input + return getNextNumberSegmentValue(delta, segmentValue, 0, 23, 2); + } + if (selectionStart === 17 || selectionStart === 14) { + // Minutes/seconds input + return getNextNumberSegmentValue(delta, segmentValue, 0, 59, 2); + } + if (selectionStart === 20 || selectionStart === 24 || selectionStart === 28) { + // Milli, micro, and nanosecond input + return getNextNumberSegmentValue(delta, segmentValue, 0, 999, 3); + } + + return segmentValue; +} diff --git a/packages/components/src/DateTimeInput.tsx b/packages/components/src/DateTimeInput.tsx index eedfcc5e77..6b2878aa37 100644 --- a/packages/components/src/DateTimeInput.tsx +++ b/packages/components/src/DateTimeInput.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import Log from '@deephaven/log'; -import type { SelectionSegment } from './MaskedInput'; -import MaskedInput from './MaskedInput'; +import MaskedInput, { SelectionSegment } from './MaskedInput'; +import { getNextSegmentValue } from './DateInputUtils'; const log = Log.module('DateTimeInput'); @@ -51,59 +51,6 @@ const DateTimeInput = React.forwardRef( ); const [selection, setSelection] = useState(); - function getNextNumberSegmentValue( - delta: number, - segmentValue: string, - lowerBound: number, - upperBound: number, - length: number - ) { - const modValue = upperBound - lowerBound + 1; - const newSegmentValue = - ((((parseInt(segmentValue, 10) - delta - lowerBound) % modValue) + - modValue) % - modValue) + - lowerBound; - const result = `${newSegmentValue}`.padStart(length, '0'); - log.debug('getNextNumberSegmentValue', modValue, newSegmentValue, result); - return result; - } - - function getNextSegmentValue( - range: SelectionSegment, - delta: number, - segmentValue: string - ): string { - const { selectionStart } = range; - if (selectionStart === 0) { - return getNextNumberSegmentValue(delta, segmentValue, 1900, 2099, 4); - } - if (selectionStart === 5) { - return getNextNumberSegmentValue(delta, segmentValue, 1, 12, 2); - } - if (selectionStart === 8) { - return getNextNumberSegmentValue(delta, segmentValue, 1, 31, 2); - } - if (selectionStart === 11) { - // Hours input - return getNextNumberSegmentValue(delta, segmentValue, 0, 23, 2); - } - if (selectionStart === 17 || selectionStart === 14) { - // Minutes/seconds input - return getNextNumberSegmentValue(delta, segmentValue, 0, 59, 2); - } - if ( - selectionStart === 20 || - selectionStart === 24 || - selectionStart === 28 - ) { - // Milli, micro, and nanosecond input - return getNextNumberSegmentValue(delta, segmentValue, 0, 999, 3); - } - - return segmentValue; - } - const handleChange = useCallback( (newValue: string): void => { log.debug('handleChange', newValue); From beaa7ade6fdcce999f7025de56c987fd8340d7cc Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 09:56:20 -0600 Subject: [PATCH 18/19] Fix validation issue, cleanup tests --- packages/components/src/MaskedInput.tsx | 2 +- packages/components/src/TimeInput.test.tsx | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/components/src/MaskedInput.tsx b/packages/components/src/MaskedInput.tsx index 91636f6577..e224d9eca7 100644 --- a/packages/components/src/MaskedInput.tsx +++ b/packages/components/src/MaskedInput.tsx @@ -234,7 +234,7 @@ const MaskedInput = React.forwardRef( checkValue: string, cursorPosition = checkValue.length ): boolean { - const patternRegex = new RegExp(pattern); + const patternRegex = new RegExp(`^${pattern}$`); if (patternRegex.test(checkValue)) { return true; } diff --git a/packages/components/src/TimeInput.test.tsx b/packages/components/src/TimeInput.test.tsx index 07196746f0..646fada71a 100644 --- a/packages/components/src/TimeInput.test.tsx +++ b/packages/components/src/TimeInput.test.tsx @@ -150,6 +150,12 @@ describe('select and type', () => { testSelectAndType(4, '55', '12:55:56'); testSelectAndType(1, '000000', '00:00:00'); + + // Jumps to the next section if the previous section is complete + testSelectAndType(0, '35', `03:54:56`); + + // Validates the whole value, not just a substring + testSelectAndType(9, '11`"();', `12:34:11`); }); it('handles backspace', () => { // Replace selected section with fixed-width spaces @@ -194,20 +200,14 @@ describe('select and type', () => { unmount(); }); - it('existing invalid behaviors that might need to be fixed', () => { - // Expected: '20:34:56'? + it('existing edge cases', () => { + // An edge case not worth fixing + // Ideally it should change the first section to 20, i.e. '20:34:56' testSelectAndType(1, '5{arrowleft}2', `25:34:56`); - // Fill in with zeros when skipping positions. Expected: '03:34:56' + // An edge case not worth fixing + // Ideally it should fill in with zeros when skipping positions, i.e. '03:34:56' testSelectAndType(0, '{backspace}3', `${FIXED_WIDTH_SPACE}3:34:56`); - - // Not sure it's ok to skip to the next section when the input isn't valid for the current section - // Expected: '03:34:56'? - testSelectAndType(0, '35', `03:54:56`); - - // Should validate whole value - // Expected: '12:34:11' - testSelectAndType(9, '11`"();', `12:34:11\`"();`); }); }); From a6e931731e4b25eaf934f41c6f9a87384e537b07 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 21 Sep 2022 10:20:43 -0600 Subject: [PATCH 19/19] Comment cleanup --- packages/components/src/TimeInput.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/components/src/TimeInput.test.tsx b/packages/components/src/TimeInput.test.tsx index 646fada71a..616a7a8796 100644 --- a/packages/components/src/TimeInput.test.tsx +++ b/packages/components/src/TimeInput.test.tsx @@ -201,11 +201,9 @@ describe('select and type', () => { }); it('existing edge cases', () => { - // An edge case not worth fixing // Ideally it should change the first section to 20, i.e. '20:34:56' testSelectAndType(1, '5{arrowleft}2', `25:34:56`); - // An edge case not worth fixing // Ideally it should fill in with zeros when skipping positions, i.e. '03:34:56' testSelectAndType(0, '{backspace}3', `${FIXED_WIDTH_SPACE}3:34:56`); });