mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-10 15:00:47 +01:00
Compare commits
1 Commits
fix/codemi
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7962bac4d |
@@ -84,6 +84,45 @@
|
||||
color: rgba($color: var(--l1-foreground), $alpha: 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&.invalid-flash {
|
||||
animation:
|
||||
timeSelection-input-shake 300ms ease-out,
|
||||
timeSelection-input-invalid-flash 1200ms ease-out;
|
||||
|
||||
input {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes timeSelection-input-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes timeSelection-input-invalid-flash {
|
||||
0%,
|
||||
50% {
|
||||
background-color: color-mix(in srgb, var(--bg-cherry-500) 18%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.timeSelection-input.invalid-flash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.valid-format-error {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import * as timeUtils from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
import { INVALID_FLASH_DURATION_MS } from './useInvalidFlash';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
@@ -285,4 +286,102 @@ describe('CustomTimePicker', () => {
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe('Live');
|
||||
});
|
||||
|
||||
describe('invalid entry flash', () => {
|
||||
const FLASH_START_DELAY_MS = 50;
|
||||
|
||||
const enterInvalidRange = (input: HTMLElement): void => {
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: '10/08/2026 14:30 - 10/08/2026 15:30' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
};
|
||||
|
||||
const getFieldWrapper = (input: HTMLElement): HTMLElement =>
|
||||
input.closest('.timeSelection-input') as HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('flashes the field, then clears the flash on its own', () => {
|
||||
render(<Wrapper />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
enterInvalidRange(input);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(FLASH_START_DELAY_MS);
|
||||
});
|
||||
expect(getFieldWrapper(input)).toHaveClass('invalid-flash');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(INVALID_FLASH_DURATION_MS);
|
||||
});
|
||||
expect(getFieldWrapper(input)).not.toHaveClass('invalid-flash');
|
||||
});
|
||||
|
||||
it('flashes again on a second consecutive invalid entry', () => {
|
||||
render(<Wrapper />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
enterInvalidRange(input);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(FLASH_START_DELAY_MS + INVALID_FLASH_DURATION_MS);
|
||||
});
|
||||
expect(getFieldWrapper(input)).not.toHaveClass('invalid-flash');
|
||||
|
||||
enterInvalidRange(input);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(FLASH_START_DELAY_MS);
|
||||
});
|
||||
expect(getFieldWrapper(input)).toHaveClass('invalid-flash');
|
||||
});
|
||||
|
||||
it('drops the error state when closing restores the previous value', () => {
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
enterInvalidRange(input);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(FLASH_START_DELAY_MS);
|
||||
});
|
||||
expect(getFieldWrapper(input)).toHaveClass('error');
|
||||
|
||||
// Chevron close without an intervening blur takes the branch that reverts
|
||||
// the input to the previously applied range
|
||||
fireEvent.click(
|
||||
document.querySelector('.time-input-suffix-icon-badge') as HTMLElement,
|
||||
);
|
||||
|
||||
expect(getFieldWrapper(input)).not.toHaveClass('error');
|
||||
expect((input as HTMLInputElement).value).toBe(
|
||||
'2024-01-01 00:00:00 - 2024-01-01 01:00:00',
|
||||
);
|
||||
expect(onError).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('keeps the persistent error styling after the flash has gone', () => {
|
||||
render(<Wrapper />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
enterInvalidRange(input);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(FLASH_START_DELAY_MS + INVALID_FLASH_DURATION_MS);
|
||||
});
|
||||
|
||||
expect(getFieldWrapper(input)).toHaveClass('error');
|
||||
expect(getFieldWrapper(input)).not.toHaveClass('invalid-flash');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { TimeRangeValidationResult, validateTimeRange } from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent';
|
||||
import { useInvalidFlash } from './useInvalidFlash';
|
||||
|
||||
import './CustomTimePicker.styles.scss';
|
||||
|
||||
@@ -106,6 +107,7 @@ function CustomTimePicker({
|
||||
const [inputErrorDetails, setInputErrorDetails] = useState<
|
||||
TimeRangeValidationResult['errorDetails'] | null
|
||||
>(null);
|
||||
const { isFlashing, triggerFlash } = useInvalidFlash();
|
||||
const location = useLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
@@ -275,6 +277,9 @@ function CustomTimePicker({
|
||||
if (!newOpen) {
|
||||
setCustomDTPickerVisible?.(false);
|
||||
setActiveView('datetime');
|
||||
// The rejected value is being discarded in favour of the previous one, so
|
||||
// the error it raised must not outlive it
|
||||
resetErrorStatus();
|
||||
|
||||
if (showLiveLogs) {
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
@@ -340,6 +345,7 @@ function CustomTimePicker({
|
||||
|
||||
if (minTime && (!minTime.isValid() || minTime < maxAllowedMinTime)) {
|
||||
setInputStatus(CustomTimePickerInputStatus.ERROR);
|
||||
triggerFlash();
|
||||
onError(true);
|
||||
setInputErrorDetails({
|
||||
message: `Please enter time less than ${maxAllowedMinTimeInMonths} months`,
|
||||
@@ -392,6 +398,7 @@ function CustomTimePicker({
|
||||
|
||||
if (!isValidTimeRange) {
|
||||
setInputStatus(CustomTimePickerInputStatus.ERROR);
|
||||
triggerFlash();
|
||||
onError(true);
|
||||
setInputErrorDetails(errorDetails || null);
|
||||
return;
|
||||
@@ -485,6 +492,9 @@ function CustomTimePicker({
|
||||
|
||||
setOpen(false);
|
||||
setCustomDTPickerVisible?.(false);
|
||||
// The rejected value is being discarded in favour of the previous one, so
|
||||
// the error it raised must not outlive it
|
||||
resetErrorStatus();
|
||||
|
||||
if (showLiveLogs) {
|
||||
setInputValue('Live');
|
||||
@@ -600,6 +610,7 @@ function CustomTimePicker({
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',
|
||||
isFlashing ? 'invalid-flash' : '',
|
||||
)}
|
||||
type="text"
|
||||
status={
|
||||
|
||||
47
frontend/src/components/CustomTimePicker/useInvalidFlash.ts
Normal file
47
frontend/src/components/CustomTimePicker/useInvalidFlash.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const INVALID_FLASH_DURATION_MS = 1200;
|
||||
|
||||
interface UseInvalidFlashResult {
|
||||
isFlashing: boolean;
|
||||
triggerFlash: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a one-shot "invalid input" flash that clears itself after
|
||||
* `durationMs`, leaving any persistent error styling to the caller.
|
||||
*
|
||||
* A re-trigger drops the flag for one frame before setting it again: a CSS
|
||||
* animation only restarts when the class is genuinely removed and re-added, so
|
||||
* without that gap a second failed attempt in a row would not animate.
|
||||
*/
|
||||
export function useInvalidFlash(
|
||||
durationMs: number = INVALID_FLASH_DURATION_MS,
|
||||
): UseInvalidFlashResult {
|
||||
const [isFlashing, setIsFlashing] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const frameRef = useRef<number>();
|
||||
|
||||
const clearPending = useCallback((): void => {
|
||||
if (timeoutRef.current !== undefined) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (frameRef.current !== undefined) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearPending, [clearPending]);
|
||||
|
||||
const triggerFlash = useCallback((): void => {
|
||||
clearPending();
|
||||
setIsFlashing(false);
|
||||
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
setIsFlashing(true);
|
||||
timeoutRef.current = setTimeout(() => setIsFlashing(false), durationMs);
|
||||
});
|
||||
}, [clearPending, durationMs]);
|
||||
|
||||
return { isFlashing, triggerFlash };
|
||||
}
|
||||
78
frontend/src/utils/__tests__/timeUtils.test.ts
Normal file
78
frontend/src/utils/__tests__/timeUtils.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { validateTimeRange } from 'utils/timeUtils';
|
||||
|
||||
const FORMAT = DATE_TIME_FORMATS.UK_DATETIME_SECONDS;
|
||||
const TIMEZONE = 'Africa/Lagos';
|
||||
|
||||
const inTimezone = (offsetMinutes: number): string =>
|
||||
dayjs().tz(TIMEZONE).subtract(offsetMinutes, 'minute').format(FORMAT);
|
||||
|
||||
describe('validateTimeRange', () => {
|
||||
it('accepts a well formed past range', () => {
|
||||
const result = validateTimeRange(
|
||||
inTimezone(120),
|
||||
inTimezone(60),
|
||||
FORMAT,
|
||||
TIMEZONE,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.startTimeMs).toBeLessThan(result.endTimeMs as number);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing seconds', '10/08/2026 14:30'],
|
||||
['date only', '10/08/2026'],
|
||||
['empty string', ''],
|
||||
['unparseable text', 'garbage'],
|
||||
['truncated minutes', '10/08/2026 14:3'],
|
||||
])('rejects %s without throwing', (_label, startTime) => {
|
||||
let result;
|
||||
|
||||
expect(() => {
|
||||
result = validateTimeRange(startTime, inTimezone(60), FORMAT, TIMEZONE);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isValid: false,
|
||||
errorDetails: { code: 'INVALID_DATE_TIME_FORMAT' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a missing end time instead of defaulting it to now', () => {
|
||||
const result = validateTimeRange(
|
||||
inTimezone(60),
|
||||
undefined as unknown as string,
|
||||
FORMAT,
|
||||
TIMEZONE,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorDetails?.code).toBe('INVALID_DATE_TIME_FORMAT');
|
||||
});
|
||||
|
||||
it('rejects future dates', () => {
|
||||
const result = validateTimeRange(
|
||||
inTimezone(-120),
|
||||
inTimezone(-60),
|
||||
FORMAT,
|
||||
TIMEZONE,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorDetails?.code).toBe('DATES_IN_THE_FUTURE');
|
||||
});
|
||||
|
||||
it('rejects a range where start is not before end', () => {
|
||||
const result = validateTimeRange(
|
||||
inTimezone(60),
|
||||
inTimezone(120),
|
||||
FORMAT,
|
||||
TIMEZONE,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorDetails?.code).toBe('START_TIME_AFTER_END_TIME');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
@@ -220,6 +220,23 @@ export interface TimeRangeValidationResult {
|
||||
endTimeMs?: number;
|
||||
}
|
||||
|
||||
const safeParseInTimezone = (
|
||||
value: string,
|
||||
format: string,
|
||||
timezone: string,
|
||||
): Dayjs | null => {
|
||||
if (!value || !dayjs(value, format).isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = dayjs.tz(value, format, timezone);
|
||||
return parsed.isValid() ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a start and end datetime string.
|
||||
*
|
||||
@@ -243,14 +260,12 @@ export const validateTimeRange = (
|
||||
format: string,
|
||||
timezone: string,
|
||||
): TimeRangeValidationResult => {
|
||||
const start = dayjs.tz(startTime, format, timezone);
|
||||
const end = dayjs.tz(endTime, format, timezone);
|
||||
const start = safeParseInTimezone(startTime, format, timezone);
|
||||
const end = safeParseInTimezone(endTime, format, timezone);
|
||||
const now = dayjs().tz(timezone);
|
||||
const startTimeMs = start.valueOf();
|
||||
const endTimeMs = end.valueOf();
|
||||
|
||||
// Invalid format or parsing failure
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
if (!start || !end) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
@@ -270,6 +285,9 @@ Shortcuts:
|
||||
};
|
||||
}
|
||||
|
||||
const startTimeMs = start.valueOf();
|
||||
const endTimeMs = end.valueOf();
|
||||
|
||||
// dates must not be in the future
|
||||
if (start.isAfter(now) || end.isAfter(now)) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user