Compare commits
6 Commits
main
...
feat/add-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa73b9d19 | ||
|
|
fb671872a9 | ||
|
|
6814c236b2 | ||
|
|
f1371f965e | ||
|
|
9fd4d3eeb8 | ||
|
|
e27fd996c3 |
@@ -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' : ''}`}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user