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';