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 };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
.highlights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
padding: 12px 0;
|
||||
|
||||
// Constrain each KeyValueLabel (the grid items) to its cell.
|
||||
:global(.key-value-label) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.valueBadge {
|
||||
--badge-font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge
|
||||
.badgeText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.serviceDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.traceLink {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { LOG_HIGHLIGHTS } from './config';
|
||||
import styles from './LogHighlights.module.scss';
|
||||
|
||||
interface LogHighlightsProps {
|
||||
log: ILog;
|
||||
}
|
||||
|
||||
function LogHighlights({ log }: LogHighlightsProps): JSX.Element | null {
|
||||
const fields = LOG_HIGHLIGHTS.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
value: field.render(log),
|
||||
})).filter((field) => field.value != null);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.highlights} data-testid="log-details-highlights">
|
||||
{fields.map((field) => (
|
||||
<KeyValueLabel
|
||||
key={field.key}
|
||||
badgeKey={field.label}
|
||||
badgeValue={field.value}
|
||||
direction="column"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogHighlights;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import styles from './LogHighlights.module.scss';
|
||||
|
||||
interface TraceIdFieldProps {
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
|
||||
return (
|
||||
<Link
|
||||
to={{ pathname: `/trace/${traceId}` }}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.traceLink}
|
||||
title={traceId}
|
||||
>
|
||||
{traceId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceIdField;
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import styles from './LogHighlights.module.scss';
|
||||
import TraceIdField from './TraceIdField';
|
||||
|
||||
// Severity badge color mirrors the LogStateIndicator bar
|
||||
const SEVERITY_COLOR: Record<string, BadgeColor> = {
|
||||
[LogType.TRACE]: 'forest',
|
||||
[LogType.DEBUG]: 'aqua',
|
||||
[LogType.INFO]: 'robin',
|
||||
[LogType.WARN]: 'amber',
|
||||
[LogType.ERROR]: 'cherry',
|
||||
[LogType.FATAL]: 'sakura',
|
||||
};
|
||||
|
||||
export interface LogHighlightConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
render: (log: ILog) => ReactNode | null;
|
||||
}
|
||||
|
||||
// Resource/attribute lookup (keys like `service.name` live in resources_string,
|
||||
// occasionally attributes_string). Typed loosely as these are string maps.
|
||||
const getAttr = (log: ILog, key: string): string =>
|
||||
(log.resources_string as unknown as Record<string, string>)?.[key] ||
|
||||
(log.attributes_string as unknown as Record<string, string>)?.[key] ||
|
||||
'';
|
||||
|
||||
const valueBadge = (
|
||||
value: string,
|
||||
options?: { prefix?: ReactNode; color?: BadgeColor },
|
||||
): ReactNode => (
|
||||
<Badge color={options?.color ?? 'vanilla'} className={styles.valueBadge}>
|
||||
{options?.prefix}
|
||||
<span className={styles.badgeText} title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
|
||||
{
|
||||
key: 'service',
|
||||
label: 'SERVICE',
|
||||
render: (log): ReactNode | null => {
|
||||
const value = getAttr(log, 'service.name');
|
||||
return value
|
||||
? valueBadge(value, {
|
||||
prefix: <span className={styles.serviceDot} />,
|
||||
})
|
||||
: null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
label: 'SEVERITY',
|
||||
render: (log): ReactNode | null => {
|
||||
if (!log.severity_text) {
|
||||
return null;
|
||||
}
|
||||
return valueBadge(log.severity_text, {
|
||||
color: SEVERITY_COLOR[getLogIndicatorType(log)] ?? 'vanilla',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'namespace',
|
||||
label: 'NAMESPACE',
|
||||
render: (log): ReactNode | null => {
|
||||
const value = getAttr(log, 'service.namespace');
|
||||
return value ? valueBadge(value) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'environment',
|
||||
label: 'ENVIRONMENT',
|
||||
render: (log): ReactNode | null => {
|
||||
const value = getAttr(log, 'deployment.environment');
|
||||
return value ? valueBadge(value) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'traceId',
|
||||
label: 'TRACE ID',
|
||||
render: (log): ReactNode | null => {
|
||||
const traceId = log.trace_id || log.traceId;
|
||||
return traceId ? <TraceIdField traceId={traceId} /> : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'spanId',
|
||||
label: 'SPAN ID',
|
||||
render: (log): ReactNode | null => {
|
||||
const spanId = log.span_id || log.spanID;
|
||||
return spanId ? valueBadge(spanId) : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -115,45 +115,6 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
expect(screen.queryByText('Open in Explorer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Highlights for fields present on the log, omitting absent ones', () => {
|
||||
const logWithMeta = {
|
||||
...mockLog,
|
||||
severity_text: 'ERROR',
|
||||
trace_id: 'trace-abc',
|
||||
resources_string: {
|
||||
'service.name': 'checkout',
|
||||
'deployment.environment': 'production',
|
||||
},
|
||||
} as unknown as ILog;
|
||||
|
||||
renderDrawer({ log: logWithMeta });
|
||||
|
||||
const highlights = screen.getByTestId('log-details-highlights');
|
||||
expect(highlights).toHaveTextContent('SEVERITY');
|
||||
expect(highlights).toHaveTextContent('ERROR');
|
||||
expect(highlights).toHaveTextContent('SERVICE');
|
||||
expect(highlights).toHaveTextContent('checkout');
|
||||
expect(highlights).toHaveTextContent('ENVIRONMENT');
|
||||
expect(highlights).toHaveTextContent('production');
|
||||
expect(highlights).toHaveTextContent('TRACE ID');
|
||||
// Absent fields are omitted (no namespace / span id on this log).
|
||||
expect(highlights).not.toHaveTextContent('NAMESPACE');
|
||||
expect(highlights).not.toHaveTextContent('SPAN ID');
|
||||
});
|
||||
|
||||
it('links the trace id highlight to the trace detail in a new tab', () => {
|
||||
const logWithTrace = {
|
||||
...mockLog,
|
||||
trace_id: 'trace-abc',
|
||||
} as unknown as ILog;
|
||||
|
||||
renderDrawer({ log: logWithTrace });
|
||||
|
||||
const link = screen.getByRole('link', { name: 'trace-abc' });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link.getAttribute('href')).toContain('/trace/trace-abc');
|
||||
});
|
||||
|
||||
it('navigates to the next / previous log with the Down / Up arrow keys', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const logs = [makeLog('log-0'), makeLog('log-1'), makeLog('log-2')];
|
||||
|
||||
@@ -55,7 +55,6 @@ import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -400,8 +399,6 @@ function LogDetailInner({
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
|
||||
{isLogDetailsV2 && <LogHighlights log={log} />}
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
|
||||
@@ -183,14 +183,15 @@ function QuerySearch({
|
||||
isProgrammaticChangeRef.current = true;
|
||||
}
|
||||
|
||||
const changes = view.state.changes({
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value,
|
||||
});
|
||||
view.dispatch({
|
||||
changes,
|
||||
selection: { anchor: changes.newLength },
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value,
|
||||
},
|
||||
selection: {
|
||||
anchor: value.length,
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -301,66 +301,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not crash when the expression contains CRLF line breaks (issue #5869)', async () => {
|
||||
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
|
||||
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
|
||||
const initialExpression = "service.name = 'frontend'";
|
||||
// Filtering on a multi-line log value (CRLF) used to throw
|
||||
// "RangeError: Selection points outside of document".
|
||||
const crlfExpression = "body CONTAINS 'line1\r\nline2\r\nline3'";
|
||||
|
||||
const baseQueryData = {
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filter: { expression: initialExpression },
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={baseQueryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const editorContent = document.querySelector(
|
||||
CM_EDITOR_SELECTOR,
|
||||
) as HTMLElement;
|
||||
expect(editorContent.textContent || '').toBe(initialExpression);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
rerender(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={{ ...baseQueryData, filter: { expression: crlfExpression } }}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The programmatic replace dispatched without throwing, and the selection anchor
|
||||
// stayed within the CRLF-normalized document (the bug set it past the end).
|
||||
await waitFor(() => {
|
||||
const spec = dispatchSpy.mock.calls
|
||||
.map(
|
||||
(call) =>
|
||||
call[0] as {
|
||||
selection?: { anchor?: number };
|
||||
changes?: { newLength?: number };
|
||||
},
|
||||
)
|
||||
.find((s) => s?.selection?.anchor != null && s?.changes?.newLength != null);
|
||||
expect(spec).toBeDefined();
|
||||
expect(spec?.selection?.anchor).toBeLessThanOrEqual(
|
||||
spec?.changes?.newLength as number,
|
||||
);
|
||||
});
|
||||
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
|
||||
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