Skip to content
64 changes: 59 additions & 5 deletions packages/components/src/DateTimeInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ it('adds missing trailing zeros', async () => {
initialSelectionEnd: 23,
});
expect(input.value).toEqual(`2022-02-22 00:00:00.100`);
expect(onChange).toBeCalledWith(`2022-02-22 00:00:00.100000000`);
expect(onChange).toHaveBeenCalledWith(`2022-02-22 00:00:00.100000000`);

await user.keyboard('{Backspace}');
expect(input.value).toEqual(`2022-02-22 00:00:00.${F}00`);
expect(onChange).toBeCalledWith(`2022-02-22 00:00:00.000000000`);
expect(onChange).toHaveBeenCalledWith(`2022-02-22 00:00:00.000000000`);

unmount();
});
Expand Down Expand Up @@ -105,7 +105,7 @@ it('fills missing time digits with zeros, strips zero-width spaces in onChange',
expect(input.value).toEqual(
`2022-02-22 11:${F}${F}:11.${F}${F}${F}${Z}111${Z}111`
);
expect(onChange).toBeCalledWith(`2022-02-22 11:00:11.000111111`);
expect(onChange).toHaveBeenCalledWith(`2022-02-22 11:00:11.000111111`);

unmount();
});
Expand All @@ -126,7 +126,7 @@ it('does not fill in missing date digits', async () => {
initialSelectionEnd: 7,
});
expect(input.value).toEqual(`2022-${F}${F}-22 00:00:00.000`);
expect(onChange).toBeCalledWith(`2022-${F}${F}-22 00:00:00.000000000`);
expect(onChange).toHaveBeenCalledWith(`2022-${F}${F}-22 00:00:00.000000000`);

unmount();
});
Expand Down Expand Up @@ -155,6 +155,60 @@ it('onSubmit works correctly', async () => {
const input: HTMLInputElement = screen.getByRole('textbox');
const user = userEvent.setup();
await user.type(input, '{enter}');
expect(onSubmit).toBeCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
unmount();
});

describe('normalizeText', () => {
Comment thread
dgodinez-dh marked this conversation as resolved.
it.each([
[
'replaces T separator with space for ISO 8601 format',
'2022-02-22T12:30:45.123456789',
'2022-02-22 12:30:45.123456789',
],
[
'removes timezone information (Z)',
'2022-02-22T12:30:45.123456789Z',
'2022-02-22 12:30:45.123456789',
],
[
'removes timezone information (offset)',
'2022-02-22T12:30:45.123456789+05:00',
'2022-02-22 12:30:45.123456789',
],
[
'removes timezone information (named)',
'2022-02-22 12:30:45.123456789 EDT',
'2022-02-22 12:30:45.123456789',
],
[
'handles datetime without fractional seconds',
'2022-02-22T12:30:45',
'2022-02-22 12:30:45.000000000',
],
])('%s', async (_, pastedText, expectedValue) => {
const user = userEvent.setup();
const onChange = jest.fn();
const { unmount } = makeDateTimeInput({ onChange });
const input: HTMLInputElement = screen.getByRole('textbox');

input.focus();
await user.paste(pastedText);

expect(onChange).toHaveBeenCalledWith(expectedValue);
unmount();
});

it('adds zero-width space separators between nano/micro/milliseconds', async () => {
Comment thread
bmingles marked this conversation as resolved.
const user = userEvent.setup();
const onChange = jest.fn();
const { unmount } = makeDateTimeInput({ onChange });
const input: HTMLInputElement = screen.getByRole('textbox');

input.focus();
await user.paste('2022-02-22 12:30:45.123456789');

expect(input.value).toBe(`2022-02-22 12:30:45.123${Z}456${Z}789`);
unmount();
});
});
49 changes: 48 additions & 1 deletion packages/components/src/DateTimeInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import React, { type KeyboardEvent, useCallback, useState } from 'react';
import React, {
type KeyboardEvent,
useCallback,
useEffect,
useState,
} from 'react';
import classNames from 'classnames';
import Log from '@deephaven/log';
import MaskedInput, { type SelectionSegment } from './MaskedInput';
Expand Down Expand Up @@ -61,6 +66,47 @@ export const DateTimeInput = React.forwardRef<
);
const [selection, setSelection] = useState<SelectionSegment>();

/**
* Normalize text by:
* - Replacing 'T' with space to support ISO 8601 format
* - Removing timezone information (e.g., "EDT", "+05:00", "Z")
* - Adding zero-width space separators in the nanosecond part
* @param text The text
* @returns The normalized text
*/
const normalizeText = useCallback((text: string): string => {
// Replace first 'T' separator with space for ISO 8601 format (without global flag to preserve 'T' in timezone like EDT)
let normalized = text.replace(/T/, ' ');

// Remove timezone information
// Match datetime up to optional fractional seconds, then strip everything else
// Pattern: YYYY-MM-DD HH:MM:SS[.SSSSSSSSS] followed by optional timezone
const dateTimeMatch = normalized.match(
/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/
);

if (dateTimeMatch) {
[, normalized] = dateTimeMatch;
}

// Add zero-width space separators to match the expected pattern
return addSeparators(normalized);
}, []);

// Sync internal state with defaultValue prop when it changes
// Apply normalization to handle raw unformatted values (e.g., with timezone info)
useEffect(() => {
if (defaultValue.length > 0) {
const normalized = normalizeText(defaultValue);
setValue(normalized);
// Notify parent with the normalized value (without separators)
onChange(fixIncompleteValue(removeSeparators(normalized)));
} else {
setValue('');
onChange('');
}
}, [defaultValue, normalizeText, onChange]);

const handleChange = useCallback(
(newValue: string): void => {
log.debug('handleChange', newValue);
Expand Down Expand Up @@ -88,6 +134,7 @@ export const DateTimeInput = React.forwardRef<
className={classNames(className)}
example={EXAMPLES}
getNextSegmentValue={getNextSegmentValue}
normalizePastedText={normalizeText}
onChange={handleChange}
onSelect={setSelection}
onSubmit={onSubmit}
Expand Down
71 changes: 56 additions & 15 deletions packages/components/src/MaskedInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type MaskedInputProps = {
selectionStart: number,
selectionEnd: number
) => string;
/** Normalize pasted text before validation. Defaults to returning text unchanged. */
normalizePastedText?: (text: string) => string;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;

Expand All @@ -87,6 +89,7 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
example,
getNextSegmentValue = (range, delta, segmentValue) => segmentValue,
getPreferredReplacementString = DEFAULT_GET_PREFERRED_REPLACEMENT_STRING,
normalizePastedText = (text: string) => text,
onChange = () => false,
onSelect = () => false,
onSubmit,
Expand Down Expand Up @@ -196,23 +199,27 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
* @param checkValue The value to check validity of
* @param cursorPosition The position of the cursor to check up to
*/
function isValid(
checkValue: string,
cursorPosition = checkValue.length
): boolean {
const patternRegex = new RegExp(`^${pattern}$`);
if (patternRegex.test(checkValue)) {
return true;
}

for (let i = 0; i < examples.length; i += 1) {
const filledValue = fillValue(checkValue, examples[i], cursorPosition);
if (patternRegex.test(filledValue)) {
const isValid = useCallback(
(checkValue: string, cursorPosition = checkValue.length): boolean => {
const patternRegex = new RegExp(`^${pattern}$`);
if (patternRegex.test(checkValue)) {
return true;
}
}
return false;
}

for (let i = 0; i < examples.length; i += 1) {
const filledValue = fillValue(
checkValue,
examples[i],
cursorPosition
);
if (patternRegex.test(filledValue)) {
return true;
}
}
return false;
},
[pattern, examples]
);

/**
* Returns the next segment after the given position
Expand Down Expand Up @@ -379,6 +386,39 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
}
}

/**
* Handles paste events into the input
* @param event The paste event
*/
const handlePaste = useCallback(
(event: React.ClipboardEvent<HTMLInputElement>): void => {
event.preventDefault();
event.stopPropagation();

if (!input.current) {
return;
}

const pastedText = event.clipboardData.getData('text/plain');

log.debug('handlePaste', pastedText);

// Normalize the pasted text
const normalizedText = normalizePastedText(pastedText);

// Check if the pasted value is valid
if (isValid(normalizedText, normalizedText.length)) {
onChange(normalizedText);
onSelect({
selectionStart: normalizedText.length,
selectionEnd: normalizedText.length,
selectionDirection: SELECTION_DIRECTION.NONE,
});
}
},
[input, onChange, onSelect, isValid, normalizePastedText]
);

function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
if (!input.current) {
return;
Expand Down Expand Up @@ -547,6 +587,7 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
value={value}
onChange={() => undefined}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onSelect={handleSelect}
onSelectCapture={handleSelectCapture}
onFocus={onFocus}
Expand Down
6 changes: 3 additions & 3 deletions packages/iris-grid/src/IrisGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2986,10 +2986,10 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
}
// if a row is selected
const { model } = this.props;
const { name, type } = model.columns[cursorColumn];
const { name } = model.columns[cursorColumn];

const cellValue = model.valueForCell(cursorColumn, cursorRow);
const text = IrisGridUtils.convertValueToText(cellValue, type);
// Use raw value (same as Copy Cell Unformatted) to preserve full precision and timezone
const text = String(this.getValueForCell(cursorColumn, cursorRow, true));
this.setState({
isGotoShown: !isGotoShown,
gotoRow: `${cursorRow + 1}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,6 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
assertNotNull(modelRow);
const sourceCell = model.sourceForCell(modelColumn, modelRow);
const { column: sourceColumn, row: sourceRow } = sourceCell;
const value = model.valueForCell(sourceColumn, sourceRow);
const { selectedRanges } = irisGrid.state;

const column = columns[sourceColumn];
Expand Down Expand Up @@ -505,8 +504,13 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
shortcut: SHORTCUTS.TABLE.GOTO_ROW,
group: IrisGridContextMenuHandler.GROUP_GOTO,
order: 10,
action: () =>
this.irisGrid.toggleGotoRow(`${rowIndex + 1}`, `${value}`, column.name),
action: () => {
// Use raw value (same as Copy Cell Unformatted) to preserve full precision and timezone
const rawValue = String(
irisGrid.getValueForCell(columnIndex, rowIndex, true)
);
this.irisGrid.toggleGotoRow(`${rowIndex + 1}`, rawValue, column.name);
},
};
actions.push(gotoRow);

Expand Down
Loading