Skip to content

Commit 70a3085

Browse files
authored
fix: DH-21722: Fix Goto Value for Timestamps (#2633)
Fixes three issues with goto timestamp: - Right click menu option now populates with correctly formatted and working timestamp - Hot key goto now works - Paste unformatted timestamp now works
1 parent 6843d78 commit 70a3085

5 files changed

Lines changed: 173 additions & 27 deletions

File tree

packages/components/src/DateTimeInput.test.tsx

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ it('adds missing trailing zeros', async () => {
7373
initialSelectionEnd: 23,
7474
});
7575
expect(input.value).toEqual(`2022-02-22 00:00:00.100`);
76-
expect(onChange).toBeCalledWith(`2022-02-22 00:00:00.100000000`);
76+
expect(onChange).toHaveBeenCalledWith(`2022-02-22 00:00:00.100000000`);
7777

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

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

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

131131
unmount();
132132
});
@@ -155,6 +155,60 @@ it('onSubmit works correctly', async () => {
155155
const input: HTMLInputElement = screen.getByRole('textbox');
156156
const user = userEvent.setup();
157157
await user.type(input, '{enter}');
158-
expect(onSubmit).toBeCalledTimes(1);
158+
expect(onSubmit).toHaveBeenCalledTimes(1);
159159
unmount();
160160
});
161+
162+
describe('normalizeText', () => {
163+
it.each([
164+
[
165+
'replaces T separator with space for ISO 8601 format',
166+
'2022-02-22T12:30:45.123456789',
167+
'2022-02-22 12:30:45.123456789',
168+
],
169+
[
170+
'removes timezone information (Z)',
171+
'2022-02-22T12:30:45.123456789Z',
172+
'2022-02-22 12:30:45.123456789',
173+
],
174+
[
175+
'removes timezone information (offset)',
176+
'2022-02-22T12:30:45.123456789+05:00',
177+
'2022-02-22 12:30:45.123456789',
178+
],
179+
[
180+
'removes timezone information (named)',
181+
'2022-02-22 12:30:45.123456789 EDT',
182+
'2022-02-22 12:30:45.123456789',
183+
],
184+
[
185+
'handles datetime without fractional seconds',
186+
'2022-02-22T12:30:45',
187+
'2022-02-22 12:30:45.000000000',
188+
],
189+
])('%s', async (_, pastedText, expectedValue) => {
190+
const user = userEvent.setup();
191+
const onChange = jest.fn();
192+
const { unmount } = makeDateTimeInput({ onChange });
193+
const input: HTMLInputElement = screen.getByRole('textbox');
194+
195+
input.focus();
196+
await user.paste(pastedText);
197+
198+
expect(onChange).toHaveBeenCalledWith(expectedValue);
199+
unmount();
200+
});
201+
202+
it('adds zero-width space separators between nano/micro/milliseconds', async () => {
203+
const user = userEvent.setup();
204+
const onChange = jest.fn();
205+
const { unmount } = makeDateTimeInput({ onChange });
206+
const input: HTMLInputElement = screen.getByRole('textbox');
207+
208+
input.focus();
209+
await user.paste('2022-02-22 12:30:45.123456789');
210+
211+
expect(input.value).toBe(`2022-02-22 12:30:45.123${Z}456${Z}789`);
212+
unmount();
213+
});
214+
});

packages/components/src/DateTimeInput.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import React, { type KeyboardEvent, useCallback, useState } from 'react';
1+
import React, {
2+
type KeyboardEvent,
3+
useCallback,
4+
useEffect,
5+
useState,
6+
} from 'react';
27
import classNames from 'classnames';
38
import Log from '@deephaven/log';
49
import MaskedInput, { type SelectionSegment } from './MaskedInput';
@@ -61,6 +66,47 @@ export const DateTimeInput = React.forwardRef<
6166
);
6267
const [selection, setSelection] = useState<SelectionSegment>();
6368

69+
/**
70+
* Normalize text by:
71+
* - Replacing 'T' with space to support ISO 8601 format
72+
* - Removing timezone information (e.g., "EDT", "+05:00", "Z")
73+
* - Adding zero-width space separators in the nanosecond part
74+
* @param text The text
75+
* @returns The normalized text
76+
*/
77+
const normalizeText = useCallback((text: string): string => {
78+
// Replace first 'T' separator with space for ISO 8601 format (without global flag to preserve 'T' in timezone like EDT)
79+
let normalized = text.replace(/T/, ' ');
80+
81+
// Remove timezone information
82+
// Match datetime up to optional fractional seconds, then strip everything else
83+
// Pattern: YYYY-MM-DD HH:MM:SS[.SSSSSSSSS] followed by optional timezone
84+
const dateTimeMatch = normalized.match(
85+
/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/
86+
);
87+
88+
if (dateTimeMatch) {
89+
[, normalized] = dateTimeMatch;
90+
}
91+
92+
// Add zero-width space separators to match the expected pattern
93+
return addSeparators(normalized);
94+
}, []);
95+
96+
// Sync internal state with defaultValue prop when it changes
97+
// Apply normalization to handle raw unformatted values (e.g., with timezone info)
98+
useEffect(() => {
99+
if (defaultValue.length > 0) {
100+
const normalized = normalizeText(defaultValue);
101+
setValue(normalized);
102+
// Notify parent with the normalized value (without separators)
103+
onChange(fixIncompleteValue(removeSeparators(normalized)));
104+
} else {
105+
setValue('');
106+
onChange('');
107+
}
108+
}, [defaultValue, normalizeText, onChange]);
109+
64110
const handleChange = useCallback(
65111
(newValue: string): void => {
66112
log.debug('handleChange', newValue);
@@ -88,6 +134,7 @@ export const DateTimeInput = React.forwardRef<
88134
className={classNames(className)}
89135
example={EXAMPLES}
90136
getNextSegmentValue={getNextSegmentValue}
137+
normalizePastedText={normalizeText}
91138
onChange={handleChange}
92139
onSelect={setSelection}
93140
onSubmit={onSubmit}

packages/components/src/MaskedInput.tsx

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ type MaskedInputProps = {
6767
selectionStart: number,
6868
selectionEnd: number
6969
) => string;
70+
/** Normalize pasted text before validation. Defaults to returning text unchanged. */
71+
normalizePastedText?: (text: string) => string;
7072
onFocus?: React.FocusEventHandler;
7173
onBlur?: React.FocusEventHandler;
7274

@@ -87,6 +89,7 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
8789
example,
8890
getNextSegmentValue = (range, delta, segmentValue) => segmentValue,
8991
getPreferredReplacementString = DEFAULT_GET_PREFERRED_REPLACEMENT_STRING,
92+
normalizePastedText = (text: string) => text,
9093
onChange = () => false,
9194
onSelect = () => false,
9295
onSubmit,
@@ -196,23 +199,27 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
196199
* @param checkValue The value to check validity of
197200
* @param cursorPosition The position of the cursor to check up to
198201
*/
199-
function isValid(
200-
checkValue: string,
201-
cursorPosition = checkValue.length
202-
): boolean {
203-
const patternRegex = new RegExp(`^${pattern}$`);
204-
if (patternRegex.test(checkValue)) {
205-
return true;
206-
}
207-
208-
for (let i = 0; i < examples.length; i += 1) {
209-
const filledValue = fillValue(checkValue, examples[i], cursorPosition);
210-
if (patternRegex.test(filledValue)) {
202+
const isValid = useCallback(
203+
(checkValue: string, cursorPosition = checkValue.length): boolean => {
204+
const patternRegex = new RegExp(`^${pattern}$`);
205+
if (patternRegex.test(checkValue)) {
211206
return true;
212207
}
213-
}
214-
return false;
215-
}
208+
209+
for (let i = 0; i < examples.length; i += 1) {
210+
const filledValue = fillValue(
211+
checkValue,
212+
examples[i],
213+
cursorPosition
214+
);
215+
if (patternRegex.test(filledValue)) {
216+
return true;
217+
}
218+
}
219+
return false;
220+
},
221+
[pattern, examples]
222+
);
216223

217224
/**
218225
* Returns the next segment after the given position
@@ -379,6 +386,39 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
379386
}
380387
}
381388

389+
/**
390+
* Handles paste events into the input
391+
* @param event The paste event
392+
*/
393+
const handlePaste = useCallback(
394+
(event: React.ClipboardEvent<HTMLInputElement>): void => {
395+
event.preventDefault();
396+
event.stopPropagation();
397+
398+
if (!input.current) {
399+
return;
400+
}
401+
402+
const pastedText = event.clipboardData.getData('text/plain');
403+
404+
log.debug('handlePaste', pastedText);
405+
406+
// Normalize the pasted text
407+
const normalizedText = normalizePastedText(pastedText);
408+
409+
// Check if the pasted value is valid
410+
if (isValid(normalizedText, normalizedText.length)) {
411+
onChange(normalizedText);
412+
onSelect({
413+
selectionStart: normalizedText.length,
414+
selectionEnd: normalizedText.length,
415+
selectionDirection: SELECTION_DIRECTION.NONE,
416+
});
417+
}
418+
},
419+
[input, onChange, onSelect, isValid, normalizePastedText]
420+
);
421+
382422
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
383423
if (!input.current) {
384424
return;
@@ -547,6 +587,7 @@ const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
547587
value={value}
548588
onChange={() => undefined}
549589
onKeyDown={handleKeyDown}
590+
onPaste={handlePaste}
550591
onSelect={handleSelect}
551592
onSelectCapture={handleSelectCapture}
552593
onFocus={onFocus}

packages/iris-grid/src/IrisGrid.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2986,10 +2986,10 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
29862986
}
29872987
// if a row is selected
29882988
const { model } = this.props;
2989-
const { name, type } = model.columns[cursorColumn];
2989+
const { name } = model.columns[cursorColumn];
29902990

2991-
const cellValue = model.valueForCell(cursorColumn, cursorRow);
2992-
const text = IrisGridUtils.convertValueToText(cellValue, type);
2991+
// Use raw value (same as Copy Cell Unformatted) to preserve full precision and timezone
2992+
const text = String(this.getValueForCell(cursorColumn, cursorRow, true));
29932993
this.setState({
29942994
isGotoShown: !isGotoShown,
29952995
gotoRow: `${cursorRow + 1}`,

packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,6 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
425425
assertNotNull(modelRow);
426426
const sourceCell = model.sourceForCell(modelColumn, modelRow);
427427
const { column: sourceColumn, row: sourceRow } = sourceCell;
428-
const value = model.valueForCell(sourceColumn, sourceRow);
429428
const { selectedRanges } = irisGrid.state;
430429

431430
const column = columns[sourceColumn];
@@ -505,8 +504,13 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
505504
shortcut: SHORTCUTS.TABLE.GOTO_ROW,
506505
group: IrisGridContextMenuHandler.GROUP_GOTO,
507506
order: 10,
508-
action: () =>
509-
this.irisGrid.toggleGotoRow(`${rowIndex + 1}`, `${value}`, column.name),
507+
action: () => {
508+
// Use raw value (same as Copy Cell Unformatted) to preserve full precision and timezone
509+
const rawValue = String(
510+
irisGrid.getValueForCell(columnIndex, rowIndex, true)
511+
);
512+
this.irisGrid.toggleGotoRow(`${rowIndex + 1}`, rawValue, column.name);
513+
},
510514
};
511515
actions.push(gotoRow);
512516

0 commit comments

Comments
 (0)