Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
b7962bac4d feat: update datepicker interaction 2026-08-10 18:05:58 +05:30
14 changed files with 299 additions and 384 deletions

View File

@@ -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 {

View File

@@ -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');
});
});
});

View File

@@ -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={

View 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 };
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
},
},
];

View File

@@ -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')];

View File

@@ -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">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"

View 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');
});
});

View File

@@ -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 {

View File

@@ -1,78 +0,0 @@
package telemetrytypes
import "strings"
// LogicalField is one queryable field. Its Name is the spelling that the
// request used. Its Members are the physical keys that store the field.
// LogicalField is the output type of name resolution: resolution changes a
// referenced name into logical fields, and compilers make SQL from them.
//
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
// fields have the same name. Each logical field in the slice gets its own
// condition. The operator tells the compiler how to connect the conditions.
//
// One LogicalField with more than one member shows a semantic-convention
// family. A family is one field that has more than one spelling. The members
// are in current-first order. The compiler merges the members into one
// expression, and the current name wins.
//
// Members always has one entry or more. A field that is not a family has
// exactly one member. The members point to the metadata map entries. Do not
// change the members.
type LogicalField struct {
// Name is the spelling that the request used. Aliases, series labels,
// and warnings use this spelling. Because of this, the response shows
// the same spelling as the request.
Name string
// Signal, FieldContext, and FieldDataType are the identity that all
// members share. Members with a different signal, field context, or
// data type are parts of different logical fields.
Signal Signal
FieldContext FieldContext
FieldDataType FieldDataType
// Members are the physical keys that store this field, in current-first
// order. Each member has its own physical data (Materialized,
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
// from the other members.
Members []*TelemetryFieldKey
}
// SingleLogicalField makes a logical field that has one physical key.
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
return &LogicalField{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
Members: []*TelemetryFieldKey{key},
}
}
// Single returns the only member of a single-member field. A decision that
// uses only the shared identity can also use Single on a family. This is
// safe because all members have the same signal, context, and data type.
func (l *LogicalField) Single() *TelemetryFieldKey {
return l.Members[0]
}
// IsFamily returns true when the field has more than one physical member.
func (l *LogicalField) IsFamily() bool {
return len(l.Members) > 1
}
// String implements fmt.Stringer. A single-member field prints as its
// member. Because of this, a message made from the field and a message made
// from the key are the same. A family prints its shared identity and its
// member spellings.
func (l *LogicalField) String() string {
if len(l.Members) == 1 {
return l.Members[0].String()
}
names := make([]string, 0, len(l.Members))
for _, member := range l.Members {
names = append(names, member.Name)
}
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
}

View File

@@ -1,50 +0,0 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
logical := SingleLogicalField("resource.service.name", key)
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
assert.Equal(t, key.Signal, logical.Signal)
assert.Equal(t, key.FieldContext, logical.FieldContext)
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
assert.False(t, logical.IsFamily())
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
}
func TestStringDelegatesForSingleMember(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
"a message made from a single-member field must be the same as a message made from the key")
}
func TestStringListsFamilyMembers(t *testing.T) {
logical := &LogicalField{
Name: "deployment.environment.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
Members: []*TelemetryFieldKey{
{Name: "deployment.environment.name"},
{Name: "deployment.environment"},
},
}
assert.True(t, logical.IsFamily())
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
}