Compare commits

...

3 Commits

2 changed files with 326 additions and 1 deletions

View File

@@ -0,0 +1,279 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import dayjs from 'dayjs';
import * as timeUtils from 'utils/timeUtils';
import CustomTimePicker from './CustomTimePicker';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return {
...actual,
useLocation: jest.fn().mockReturnValue({
pathname: '/test-path',
}),
};
});
jest.mock('providers/Timezone', () => {
const actual = jest.requireActual('providers/Timezone');
return {
...actual,
useTimezone: jest.fn().mockReturnValue({
timezone: {
value: 'UTC',
offset: '+00:00',
name: 'UTC',
},
browserTimezone: {
value: 'UTC',
offset: '+00:00',
name: 'UTC',
},
}),
};
});
interface WrapperProps {
initialValue?: string;
showLiveLogs?: boolean;
onValidCustomDateChange?: () => void;
onError?: () => void;
onSelect?: (value: string) => void;
onCustomDateHandler?: () => void;
onCustomTimeStatusUpdate?: () => void;
}
function Wrapper({
initialValue = '2024-01-01 00:00:00 - 2024-01-01 01:00:00',
showLiveLogs = false,
onValidCustomDateChange = (): void => {},
onError = (): void => {},
onSelect = (): void => {},
onCustomDateHandler = (): void => {},
onCustomTimeStatusUpdate = (): void => {},
}: WrapperProps): JSX.Element {
const [open, setOpen] = useState(false);
const [selectedTime, setSelectedTime] = useState('custom');
const [selectedValue, setSelectedValue] = useState(initialValue);
const handleSelect = (value: string): void => {
setSelectedTime(value);
onSelect(value);
};
return (
<CustomTimePicker
open={open}
setOpen={setOpen}
onSelect={handleSelect}
onError={onError}
selectedTime={selectedTime}
selectedValue={selectedValue}
onValidCustomDateChange={({ timeStr }): void => {
setSelectedValue(timeStr);
onValidCustomDateChange();
}}
onCustomDateHandler={(): void => {
onCustomDateHandler();
}}
onCustomTimeStatusUpdate={(): void => {
onCustomTimeStatusUpdate();
}}
items={[
{ label: 'Last 5 minutes', value: '5m' },
{ label: 'Custom', value: 'custom' },
]}
minTime={dayjs('2024-01-01 00:00:00').valueOf() * 1000_000}
maxTime={dayjs('2024-01-01 01:00:00').valueOf() * 1000_000}
showLiveLogs={showLiveLogs}
/>
);
}
describe('CustomTimePicker', () => {
it('does not close or reset when clicking input while open', () => {
render(<Wrapper />);
const input = screen.getByRole('textbox');
// Open popover
fireEvent.focus(input);
// Type some text
fireEvent.change(input, { target: { value: '5m' } });
// Click the input again while open
fireEvent.mouseDown(input);
fireEvent.click(input);
// Value should remain as typed
expect((input as HTMLInputElement).value).toBe('5m');
});
it('applies valid shorthand on Enter', () => {
const onValid = jest.fn();
const onError = jest.fn();
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '5m' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onValid).toHaveBeenCalledTimes(1);
// onError(false) may be called by internal reset logic; we only assert that
// it was never called with a truthy error state
expect(onError).not.toHaveBeenCalledWith(true);
});
it('sets error and updates custom time status for invalid shorthand exceeding max allowed window', () => {
const onValid = jest.fn();
const onError = jest.fn();
const onCustomTimeStatusUpdate = jest.fn();
render(
<Wrapper
onValidCustomDateChange={onValid}
onError={onError}
onCustomTimeStatusUpdate={onCustomTimeStatusUpdate}
/>,
);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
// large number of days to ensure it exceeds the 15 months allowed window
fireEvent.change(input, { target: { value: '9999d' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onError).toHaveBeenCalledWith(true);
expect(onCustomTimeStatusUpdate).toHaveBeenCalledWith();
expect(onValid).not.toHaveBeenCalled();
});
it('treats close after change like pressing Enter (blur + chevron)', () => {
const onValid = jest.fn();
const onError = jest.fn();
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
const input = screen.getByRole('textbox');
// Open and change value so "changed since open" is true
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '5m' } });
fireEvent.blur(input);
// Click the chevron (which triggers handleClose)
const chevron = document.querySelector(
'.time-input-suffix-icon-badge',
) as HTMLElement;
fireEvent.click(chevron);
// Should have applied the value (same as Enter)
expect(onValid).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalledWith(true);
});
it('applies epoch start/end range on Enter via onCustomDateHandler', () => {
const onCustomDateHandler = jest.fn();
const onError = jest.fn();
render(
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
);
const now = dayjs().valueOf();
const later = dayjs().add(1, 'hour').valueOf();
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: `${now} - ${later}` },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalledWith(true);
});
it('uses validateTimeRange result for generic formatted ranges (valid case)', () => {
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
const onCustomDateHandler = jest.fn();
const onError = jest.fn();
validateTimeRangeSpy.mockReturnValue({
isValid: true,
errorDetails: undefined,
startTimeMs: dayjs('2024-01-01 00:00:00').valueOf(),
endTimeMs: dayjs('2024-01-01 01:00:00').valueOf(),
});
render(
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: 'foo - bar' },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(validateTimeRangeSpy).toHaveBeenCalled();
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalledWith(true);
validateTimeRangeSpy.mockRestore();
});
it('uses validateTimeRange result for generic formatted ranges (invalid case)', () => {
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
const onValid = jest.fn();
const onError = jest.fn();
validateTimeRangeSpy.mockReturnValue({
isValid: false,
errorDetails: {
message: 'Invalid range',
code: 'INVALID_RANGE',
description: 'Start must be before end',
},
startTimeMs: 0,
endTimeMs: 0,
});
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: 'foo - bar' },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(validateTimeRangeSpy).toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(true);
expect(onValid).not.toHaveBeenCalled();
validateTimeRangeSpy.mockRestore();
});
it('opens live mode with correct label', () => {
render(<Wrapper showLiveLogs />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
expect((input as HTMLInputElement).value).toBe('Live');
});
});

View File

@@ -104,6 +104,10 @@ function CustomTimePicker({
const location = useLocation();
const inputRef = useRef<InputRef>(null);
const initialInputValueOnOpenRef = useRef<string>('');
const hasChangedSinceOpenRef = useRef<boolean>(false);
// Tracks if the last pointer down was on the input so we don't close the popover when user clicks the input again
const isClickFromInputRef = useRef(false);
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
@@ -238,6 +242,21 @@ function CustomTimePicker({
};
const handleOpenChange = (newOpen: boolean): void => {
// Don't close when the user clicked the input (trigger); Ant Design treats trigger as "outside" overlay
if (!newOpen && isClickFromInputRef.current) {
isClickFromInputRef.current = false;
return;
}
isClickFromInputRef.current = false;
// If the popover is trying to close and the value changed since opening,
// treat it as if the user pressed Enter (attempt to apply the value)
if (!newOpen && hasChangedSinceOpenRef.current) {
hasChangedSinceOpenRef.current = false;
handleInputPressEnter();
return;
}
setOpen(newOpen);
if (!newOpen) {
@@ -406,10 +425,18 @@ function CustomTimePicker({
const handleOpen = (e?: React.SyntheticEvent): void => {
e?.stopPropagation?.();
// If the popover is already open, avoid resetting the input value
// so that any in-progress edits are preserved.
if (open) {
return;
}
if (showLiveLogs) {
setOpen(true);
setSelectedTimePlaceholderValue('Live');
setInputValue('Live');
initialInputValueOnOpenRef.current = 'Live';
hasChangedSinceOpenRef.current = false;
return;
}
@@ -424,11 +451,21 @@ function CustomTimePicker({
.tz(timezone.value)
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
setInputValue(`${startTime} - ${endTime}`);
const nextValue = `${startTime} - ${endTime}`;
setInputValue(nextValue);
initialInputValueOnOpenRef.current = nextValue;
hasChangedSinceOpenRef.current = false;
};
const handleClose = (e: React.MouseEvent): void => {
e.stopPropagation();
// If the value changed since opening, treat this like pressing Enter
if (hasChangedSinceOpenRef.current) {
hasChangedSinceOpenRef.current = false;
handleInputPressEnter();
return;
}
setOpen(false);
setCustomDTPickerVisible?.(false);
@@ -450,6 +487,9 @@ function CustomTimePicker({
}, [location.pathname]);
const handleInputBlur = (): void => {
// Track whether the value was changed since the input was opened for editing
hasChangedSinceOpenRef.current =
inputValue !== initialInputValueOnOpenRef.current;
resetErrorStatus();
};
@@ -552,6 +592,12 @@ function CustomTimePicker({
readOnly={!open || showLiveLogs}
placeholder={selectedTimePlaceholderValue}
value={inputValue}
onMouseDown={(e): void => {
// Only treat as "click from input" when the actual input element is clicked (not suffix/chevron)
if (e.target === inputRef.current?.input) {
isClickFromInputRef.current = true;
}
}}
onFocus={handleOpen}
onClick={handleOpen}
onChange={handleInputChange}