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
( + (props: DateInputProps, ref) => { + const { + className = '', + onChange = () => undefined, + defaultValue = '', + onFocus = () => undefined, + onBlur = () => undefined, + 'data-testid': dataTestId, + } = props; + const [value, setValue] = useState(defaultValue); + const [selection, setSelection] = useState(); + + const handleChange = useCallback( + (newValue: string): void => { + log.debug('handleChange', newValue); + setValue(newValue); + onChange(newValue); + }, + [onChange] + ); + + return ( +
+ +
+ ); + } +); + +DateInput.displayName = 'DateInput'; + +DateInput.defaultProps = { + className: '', + onChange: () => false, + defaultValue: '', + onFocus: () => false, + onBlur: () => false, + 'data-testid': undefined, +}; + +export default DateInput; 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.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 new file mode 100644 index 0000000000..6b2878aa37 --- /dev/null +++ b/packages/components/src/DateTimeInput.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import Log from '@deephaven/log'; +import MaskedInput, { SelectionSegment } from './MaskedInput'; +import { getNextSegmentValue } from './DateInputUtils'; + +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-01-01 00:00:00.000000000'; +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; + 'data-testid'?: string; +}; + +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, ''); + +const EXAMPLES = [addSeparators(DEFAULT_VALUE_STRING)]; + +const DateTimeInput = React.forwardRef( + (props: DateTimeInputProps, ref) => { + const { + className = '', + onChange = () => undefined, + defaultValue = '', + onFocus = () => undefined, + onBlur = () => undefined, + 'data-testid': dataTestId, + } = props; + const [value, setValue] = useState( + defaultValue.length > 0 ? addSeparators(defaultValue) : '' + ); + const [selection, setSelection] = useState(); + + const handleChange = useCallback( + (newValue: string): void => { + log.debug('handleChange', newValue); + setValue(newValue); + onChange(removeSeparators(newValue)); + }, + [onChange] + ); + + return ( +
+ +
+ ); + } +); + +DateTimeInput.displayName = 'DateTimeInput'; + +DateTimeInput.defaultProps = { + className: '', + onChange: () => undefined, + defaultValue: '', + onFocus: () => undefined, + onBlur: () => undefined, + 'data-testid': undefined, +}; + +export default DateTimeInput; diff --git a/packages/components/src/MaskedInput.test.tsx b/packages/components/src/MaskedInput.test.tsx index 8a5a2efd05..b0172d19cf 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, trimTrailingMask } from './MaskedInput'; const TIME_PATTERN = '([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]'; @@ -15,5 +15,29 @@ function makeMaskedInput({ } it('mounts and unmounts properly', () => { - makeMaskedInput(); + const { unmount } = makeMaskedInput(); + unmount(); +}); + +describe('fillToLength', () => { + 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'); + expect(fillToLength('te', 'TEST', 10)).toBe('teST'); + }); +}); + +describe('trimTrailingMask', () => { + 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 94519b3aed..e224d9eca7 100644 --- a/packages/components/src/MaskedInput.tsx +++ b/packages/components/src/MaskedInput.tsx @@ -30,6 +30,41 @@ export function DEFAULT_GET_PREFERRED_REPLACEMENT_STRING( ); } +/** + * 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 + * @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; +} + +/** + * 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; @@ -41,6 +76,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) */ @@ -88,6 +125,7 @@ const MaskedInput = React.forwardRef( onChange = () => false, onSelect = () => false, pattern, + placeholder, selection, value, onFocus = () => false, @@ -99,6 +137,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() { @@ -192,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; } @@ -374,6 +416,27 @@ const MaskedInput = React.forwardRef( event.preventDefault(); event.stopPropagation(); + // Deleting at the end of the value + if (selectionEnd >= trimTrailingMask(value, emptyMask).length) { + const newValue = value.substring( + 0, + // Delete whole selection or just the char before the cursor + selectionStart === selectionEnd + ? selectionStart - 1 + : selectionStart + ); + const trimmedValue = trimTrailingMask(newValue, emptyMask); + if (trimmedValue !== value) { + onChange(trimmedValue); + onSelect({ + selectionStart: trimmedValue.length, + selectionEnd: trimmedValue.length, + selectionDirection: SELECTION_DIRECTION.NONE, + }); + } + return; + } + if (selectionStart !== selectionEnd) { // Replace all non-masked characters with blanks, set selection to start const newValue = @@ -437,15 +500,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 + ? examples[0].length - 1 : selectionStart; for ( let replaceIndex = selectionStart; replaceIndex <= maxReplaceIndex; replaceIndex += 1 ) { - const newValue = getPreferredReplacementString( + // Fill with the example chars if necessary + const filledValue = fillToLength( value, + examples[0], + replaceIndex + 1 + ); + const newValue = getPreferredReplacementString( + filledValue, replaceIndex, newChar, selectionStart, @@ -484,6 +553,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 +569,7 @@ const MaskedInput = React.forwardRef( MaskedInput.defaultProps = { className: '', + placeholder: undefined, onChange(): void { // no-op }, diff --git a/packages/components/src/TimeInput.test.tsx b/packages/components/src/TimeInput.test.tsx index dd44dc0d87..616a7a8796 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(); } @@ -148,6 +150,62 @@ 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 + 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}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 edge cases', () => { + // Ideally it should change the first section to 20, i.e. '20:34:56' + testSelectAndType(1, '5{arrowleft}2', `25:34:56`); + + // 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`); }); }); 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';