Compare commits

...

6 Commits

6 changed files with 921 additions and 18 deletions

View File

@@ -1,5 +1,6 @@
import { Button, Popover, Spin, Tooltip } from 'antd';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import {
@@ -124,7 +125,7 @@ export default function AttributeActions({
);
return (
<div className="action-btn">
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}

View File

@@ -17,7 +17,7 @@
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
padding-block: 12px;
.item {
display: flex;
@@ -25,8 +25,10 @@
gap: 8px;
justify-content: flex-start;
position: relative;
padding: 2px 12px;
&:hover {
background-color: var(--bg-slate-500);
.action-btn {
display: flex;
}
@@ -81,22 +83,23 @@
.action-btn {
display: none;
&--is-open {
display: flex;
}
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
gap: 4px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
padding: 2px;
.filter-btn {
display: flex;
align-items: center;
border: none;
border-color: var(--bg-slate-400);
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-400);
background: var(--bg-slate-500);
padding: 4px;
gap: 3px;
height: 24px;
@@ -129,7 +132,7 @@
gap: 8px;
&:hover {
background-color: var(--bg-slate-400);
background-color: var(--bg-slate-400) !important;
}
}
@@ -142,6 +145,7 @@
.ant-popover-inner {
padding: 8px;
min-width: 160px;
background: var(--bg-slate-500);
}
}
@@ -149,6 +153,9 @@
.attributes-corner {
.attributes-container {
.item {
&:hover {
background-color: var(--bg-vanilla-300);
}
.item-key {
color: var(--bg-ink-100);
}
@@ -163,8 +170,6 @@
}
.action-btn {
background: rgba(255, 255, 255, 0.9);
.filter-btn {
background: var(--bg-vanilla-200);

View File

@@ -48,12 +48,52 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 10px 12px;
padding-block: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
padding: 2px 12px;
&--interactive {
&:hover {
background-color: var(--bg-slate-500);
.action-btn {
display: flex;
}
}
}
.action-btn {
display: none;
&--is-open {
display: flex;
}
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
gap: 4px;
.filter-btn {
display: flex;
align-items: center;
border-color: var(--bg-slate-400);
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-500);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--bg-slate-300);
}
}
}
.attribute-key {
color: var(--bg-vanilla-400);
@@ -238,6 +278,18 @@
color: var(--bg-ink-400);
}
.action-btn {
background: rgba(255, 255, 255, 0.9);
.filter-btn {
background: var(--bg-vanilla-200);
&:hover {
background: var(--bg-vanilla-100);
}
}
}
.value-wrapper {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);

View File

@@ -16,6 +16,7 @@ import Attributes from './Attributes/Attributes';
import { RelatedSignalsViews } from './constants';
import Events from './Events/Events';
import LinkedSpans from './LinkedSpans/LinkedSpans';
import SpanFieldActions from './SpanFieldActions/SpanFieldActions';
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
interface ISpanDetailsDrawerProps {
@@ -141,7 +142,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan && !isSpanDetailsDocked && (
<>
<section className="description">
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">span name</Typography.Text>
<Tooltip title={selectedSpan.name}>
<div className="value-wrapper">
@@ -150,14 +151,22 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text>
</div>
</Tooltip>
<SpanFieldActions
fieldDisplayName="span name"
fieldValue={selectedSpan.name}
/>
</div>
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">span id</Typography.Text>
<div className="value-wrapper">
<Typography.Text className="attribute-value">
{selectedSpan.spanId}
</Typography.Text>
</div>
<SpanFieldActions
fieldDisplayName="span id"
fieldValue={selectedSpan.spanId}
/>
</div>
<div className="item">
<Typography.Text className="attribute-key">start time</Typography.Text>
@@ -167,15 +176,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text>
</div>
</div>
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">duration</Typography.Text>
<div className="value-wrapper">
<Typography.Text className="attribute-value">
{getYAxisFormattedValue(`${selectedSpan.durationNano}`, 'ns')}
</Typography.Text>
</div>
<SpanFieldActions
fieldDisplayName="duration"
fieldValue={selectedSpan.durationNano.toString()}
/>
</div>
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">service</Typography.Text>
<div className="service">
<div className="dot" style={{ backgroundColor: color }} />
@@ -187,16 +200,24 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Tooltip>
</div>
</div>
<SpanFieldActions
fieldDisplayName="service"
fieldValue={selectedSpan.serviceName}
/>
</div>
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">span kind</Typography.Text>
<div className="value-wrapper">
<Typography.Text className="attribute-value">
{selectedSpan.spanKind}
</Typography.Text>
</div>
<SpanFieldActions
fieldDisplayName="span kind"
fieldValue={selectedSpan.spanKind}
/>
</div>
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">
status code string
</Typography.Text>
@@ -205,10 +226,14 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusCodeString}
</Typography.Text>
</div>
<SpanFieldActions
fieldDisplayName="status code string"
fieldValue={selectedSpan.statusCodeString}
/>
</div>
{selectedSpan.statusMessage && (
<div className="item">
<div className="item item--interactive">
<Typography.Text className="attribute-key">
status message
</Typography.Text>
@@ -217,6 +242,10 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusMessage}
</Typography.Text>
</div>
<SpanFieldActions
fieldDisplayName="status message"
fieldValue={selectedSpan.statusMessage}
/>
</div>
)}
<div className="item">

View File

@@ -0,0 +1,157 @@
import { Button, Popover, Spin, Tooltip } from 'antd';
import cx from 'classnames';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react';
import { useCallback, useState } from 'react';
// Field mapping from display names to actual span property keys
const SPAN_FIELD_MAPPING: Record<string, string> = {
'span name': 'name',
'span id': 'span_id',
duration: 'durationNano',
service: 'serviceName',
'span kind': 'spanKind',
'status code string': 'statusCodeString',
'status message': 'statusMessage',
};
interface SpanFieldActionsProps {
fieldDisplayName: string;
fieldValue: string;
}
export default function SpanFieldActions({
fieldDisplayName,
fieldValue,
}: SpanFieldActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const { onAddToQuery, onCopyFieldName, onCopyFieldValue } = useTraceActions();
const mappedFieldKey =
SPAN_FIELD_MAPPING[fieldDisplayName] || fieldDisplayName;
const handleFilter = useCallback(
async (operator: string, isFilterIn: boolean): Promise<void> => {
const isLoading = isFilterIn ? isFilterInLoading : isFilterOutLoading;
const setLoading = isFilterIn ? setIsFilterInLoading : setIsFilterOutLoading;
if (!onAddToQuery || isLoading) return;
setLoading(true);
try {
await onAddToQuery(mappedFieldKey, fieldValue, operator);
} finally {
setLoading(false);
}
},
[
onAddToQuery,
mappedFieldKey,
fieldValue,
isFilterInLoading,
isFilterOutLoading,
],
);
const handleFilterIn = useCallback(() => handleFilter(OPERATORS['='], true), [
handleFilter,
]);
const handleFilterOut = useCallback(
() => handleFilter(OPERATORS['!='], false),
[handleFilter],
);
const handleCopy = useCallback(
(copyFn: ((value: string) => void) | undefined, value: string): void => {
if (copyFn) {
copyFn(value);
}
setIsOpen(false);
},
[],
);
const handleCopyFieldName = useCallback(
() => handleCopy(onCopyFieldName, mappedFieldKey),
[handleCopy, onCopyFieldName, mappedFieldKey],
);
const handleCopyFieldValue = useCallback(
() => handleCopy(onCopyFieldValue, fieldValue),
[fieldValue, handleCopy, onCopyFieldValue],
);
const moreActionsContent = (
<div className="attribute-actions-menu">
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter for value"
disabled={isFilterInLoading}
icon={
isFilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterIn}
/>
</Tooltip>
<Tooltip title="Filter out value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter out value"
disabled={isFilterOutLoading}
icon={
isFilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterOut}
/>
</Tooltip>
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={moreActionsContent}
rootClassName="attribute-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,659 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { MemoryRouter, Route } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
// Mock external dependencies following the same pattern as AttributeActions tests
const mockRedirectWithQueryBuilderData = jest.fn();
const mockNotifications = {
success: jest.fn(),
error: jest.fn(),
};
const mockSetCopy = jest.fn();
const mockQueryClient = {
fetchQuery: jest.fn(),
};
// Mock the hooks - same as AttributeActions test setup
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
currentQuery: {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
},
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({ notifications: mockNotifications }),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
}));
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): any => mockQueryClient,
}));
jest.mock('@signozhq/sonner', () => ({ toast: jest.fn() }));
// Mock the API response for getAggregateKeys
const mockAggregateKeysResponse = {
payload: {
attributeKeys: [
{
key: 'name',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'serviceName',
dataType: 'string',
type: 'resource',
isColumn: true,
},
{
key: 'spanKind',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'statusCodeString',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'span_id',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'durationNano',
dataType: 'number',
type: 'tag',
isColumn: true,
},
],
},
};
beforeEach(() => {
jest.clearAllMocks();
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
});
// Create realistic mock span data for testing
const createMockSpan = (overrides: Partial<Span> = {}): Span => ({
spanId: '28a8a67365d0bd8b',
traceId: '000000000000000071dc9b0a338729b4',
name: 'HTTP GET /api/users',
timestamp: 1699872000000000,
durationNano: 150000000,
serviceName: 'frontend-service',
spanKind: 'server',
statusCodeString: 'OK',
statusMessage: '',
tagMap: {
'http.method': 'GET',
'http.url': '/api/users?page=1',
},
event: [],
references: [],
hasError: false,
rootSpanId: '',
parentSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
...overrides,
});
interface RenderResult {
user: ReturnType<typeof userEvent.setup>;
}
const renderSpanDetailsDrawer = (
span: Span = createMockSpan(),
): RenderResult => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<MockQueryClientProvider>
<AppProvider>
<MemoryRouter>
<Route>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={span}
traceStartTime={span.timestamp}
traceEndTime={span.timestamp + span.durationNano}
/>
</Route>
</MemoryRouter>
</AppProvider>
</MockQueryClientProvider>,
);
return { user };
};
describe('SpanFieldActions User Flow Tests', () => {
describe('Primary Filter Flow', () => {
it('should allow user to filter for span name value and navigate to traces explorer', async () => {
const testSpan = createMockSpan({
name: 'GET /api/orders',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the span name displayed
expect(screen.getByText('span name')).toBeInTheDocument();
expect(screen.getByText('GET /api/orders')).toBeInTheDocument();
// Find the span name field item
const spanNameItem = screen.getByText('span name').closest('.item');
expect(spanNameItem).toBeInTheDocument();
// User hovers over the span name field to reveal action buttons
await user.hover(spanNameItem!);
// Action buttons should appear on hover
const actionButtons = spanNameItem!.querySelector('.action-btn');
expect(actionButtons).toBeInTheDocument();
const filterForButton = spanNameItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
// User clicks "Filter for value" button
await user.click(filterForButton);
// Verify navigation to traces explorer with correct filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'name' }),
op: '=',
value: 'GET /api/orders',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
it('should allow user to filter for service name with proper field mapping', async () => {
const testSpan = createMockSpan({
serviceName: 'payment-service',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the service displayed
expect(screen.getByText('service')).toBeInTheDocument();
expect(screen.getByText('payment-service')).toBeInTheDocument();
// Find the service field item
const serviceItem = screen.getByText('service').closest('.item');
expect(serviceItem).toBeInTheDocument();
// User hovers and clicks filter for
await user.hover(serviceItem!);
const filterForButton = serviceItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(filterForButton);
// Verify correct field mapping: "service" display name → "serviceName" query key
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'serviceName' }),
op: '=',
value: 'payment-service',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
});
describe('Filter Out Flow', () => {
it('should allow user to exclude span kind value and navigate to traces explorer', async () => {
const testSpan = createMockSpan({
spanKind: 'client',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the span kind displayed
expect(screen.getByText('span kind')).toBeInTheDocument();
expect(screen.getByText('client')).toBeInTheDocument();
// Find the span kind field item
const spanKindItem = screen.getByText('span kind').closest('.item');
expect(spanKindItem).toBeInTheDocument();
// User hovers over the span kind field
await user.hover(spanKindItem!);
const filterOutButton = spanKindItem!.querySelector(
'[aria-label="Filter out value"]',
) as HTMLElement;
expect(filterOutButton).toBeInTheDocument();
// User clicks "Filter out value" button
await user.click(filterOutButton);
// Verify navigation to traces explorer with exclusion filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'spanKind' }),
op: '!=',
value: 'client',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
});
describe('Copy Actions Flow', () => {
it('should allow user to copy field name and field value through popover actions', async () => {
const testSpan = createMockSpan({
statusCodeString: 'ERROR',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the status code string displayed
expect(screen.getByText('status code string')).toBeInTheDocument();
expect(screen.getByText('ERROR')).toBeInTheDocument();
// Find the status code string field item
const statusCodeItem = screen
.getByText('status code string')
.closest('.item');
expect(statusCodeItem).toBeInTheDocument();
// User hovers over the field to reveal action buttons
await user.hover(statusCodeItem!);
// User clicks the more actions button (ellipsis)
const moreActionsButton = statusCodeItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
expect(moreActionsButton).toBeInTheDocument();
await user.click(moreActionsButton);
// Verify popover opens with copy options
await waitFor(() => {
expect(screen.getByText('Copy Field Name')).toBeInTheDocument();
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
// User clicks "Copy Field Name"
const copyFieldNameButton = screen.getByText('Copy Field Name');
fireEvent.click(copyFieldNameButton);
// Verify field name is copied with correct mapping
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('statusCodeString');
expect(mockNotifications.success).toHaveBeenCalledWith({
message: 'Field name copied to clipboard',
});
});
// Reset mocks and test copy field value
mockSetCopy.mockClear();
mockNotifications.success.mockClear();
// Open popover again for copy field value test
await user.hover(statusCodeItem!);
await user.click(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
// User clicks "Copy Field Value"
const copyFieldValueButton = screen.getByText('Copy Field Value');
fireEvent.click(copyFieldValueButton);
// Verify field value is copied
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('ERROR');
expect(mockNotifications.success).toHaveBeenCalledWith({
message: 'Field value copied to clipboard',
});
});
});
});
describe('Multiple Standard Fields', () => {
it('should work consistently across different field types with proper field mappings', async () => {
const testSpan = createMockSpan({
spanId: 'abc123def456',
name: 'Database Query',
serviceName: 'db-service',
spanKind: 'internal',
statusCodeString: 'OK',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// Test span ID field with its mapping
const spanIdItem = screen.getByText('span id').closest('.item');
await user.hover(spanIdItem!);
const spanIdFilterButton = spanIdItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(spanIdFilterButton);
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'span_id' }),
op: '=',
value: 'abc123def456',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
mockRedirectWithQueryBuilderData.mockClear();
// Test span name field
const spanNameItem = screen.getByText('span name').closest('.item');
await user.hover(spanNameItem!);
const spanNameFilterButton = spanNameItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(spanNameFilterButton);
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'name' }),
op: '=',
value: 'Database Query',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
});
describe('Special Field Values', () => {
it('should handle duration field with numeric values properly', async () => {
const testSpan = createMockSpan({
durationNano: 250000000, // 250ms
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the duration displayed (formatted)
expect(screen.getByText('duration')).toBeInTheDocument();
// Duration should be formatted by getYAxisFormattedValue, but we test the raw value is used in filter
// Find the duration field item
const durationItem = screen.getByText('duration').closest('.item');
expect(durationItem).toBeInTheDocument();
// User hovers and clicks filter for
await user.hover(durationItem!);
const filterForButton = durationItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
await user.click(filterForButton);
// Verify the raw numeric value is used in the filter, not the formatted display value
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'durationNano' }),
op: '=',
value: '250000000', // Raw numeric value as string
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
});
it('should handle fields with special characters and preserve exact field values', async () => {
const testSpan = createMockSpan({
name: 'POST /api/users/create',
statusCodeString: '"INTERNAL_ERROR"', // Quoted value to test exact preservation
});
const { user } = renderSpanDetailsDrawer(testSpan);
// Test span name with special characters
const spanNameItem = screen.getByText('span name').closest('.item');
await user.hover(spanNameItem!);
const moreActionsButton = spanNameItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
await user.click(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
const copyFieldValueButton = screen.getByText('Copy Field Value');
fireEvent.click(copyFieldValueButton);
// Verify special characters are handled correctly
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('POST /api/users/create');
});
// Reset and test quoted value handling
mockSetCopy.mockClear();
// Test status code string with quotes (should preserve exact value)
const statusItem = screen.getByText('status code string').closest('.item');
await user.hover(statusItem!);
const statusMoreActionsButton = statusItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
await user.click(statusMoreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
const statusCopyButton = screen.getByText('Copy Field Value');
fireEvent.click(statusCopyButton);
// Verify exact field value is preserved (including quotes)
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('"INTERNAL_ERROR"');
});
});
it('should handle status message field with action buttons when present', async () => {
const testSpan = createMockSpan({
statusMessage: 'Connection timeout error',
});
const { user } = renderSpanDetailsDrawer(testSpan);
// User sees the status message displayed
expect(screen.getByText('status message')).toBeInTheDocument();
expect(screen.getByText('Connection timeout error')).toBeInTheDocument();
// Find the status message field item
const statusMessageItem = screen
.getByText('status message')
.closest('.item');
expect(statusMessageItem).toBeInTheDocument();
// User hovers over the status message field to reveal action buttons
await user.hover(statusMessageItem!);
const filterForButton = statusMessageItem!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
// User clicks "Filter for value" button
await user.click(filterForButton);
// Verify navigation to traces explorer with correct filter mapping
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'statusMessage' }),
op: '=',
value: 'Connection timeout error',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Reset and test copy functionality
mockRedirectWithQueryBuilderData.mockClear();
// Test copy field name functionality
await user.hover(statusMessageItem!);
const moreActionsButton = statusMessageItem!
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
await user.click(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Name')).toBeInTheDocument();
});
const copyFieldNameButton = screen.getByText('Copy Field Name');
fireEvent.click(copyFieldNameButton);
// Verify field name mapping (display "status message" → query "statusMessage")
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('statusMessage');
expect(mockNotifications.success).toHaveBeenCalledWith({
message: 'Field name copied to clipboard',
});
});
});
});
});