Skip to content

Commit b48b90e

Browse files
authored
ref(trace-view): Split AttributesTreeValue into its own file (#95273)
I'm about to do a bit of work in this code, thought it'd be nice to split this out and add some specs while I'm there.
1 parent 024f081 commit b48b90e

File tree

3 files changed

+214
-70
lines changed

3 files changed

+214
-70
lines changed

static/app/views/explore/components/traceItemAttributes/attributesTree.tsx

Lines changed: 6 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import {Fragment, useMemo, useRef, useState} from 'react';
2-
import {type Theme, useTheme} from '@emotion/react';
2+
import {useTheme} from '@emotion/react';
33
import styled from '@emotion/styled';
44

55
import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
66
import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
77
import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
8-
import ExternalLink from 'sentry/components/links/externalLink';
98
import {IconEllipsis} from 'sentry/icons';
109
import {t} from 'sentry/locale';
1110
import {space} from 'sentry/styles/space';
@@ -15,12 +14,11 @@ import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
1514
import {isEmptyObject} from 'sentry/utils/object/isEmptyObject';
1615
import {isUrl} from 'sentry/utils/string/isUrl';
1716
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
18-
import {
19-
getAttributeItem,
20-
prettifyAttributeName,
21-
} from 'sentry/views/explore/components/traceItemAttributes/utils';
17+
import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAttributes/utils';
2218
import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails';
2319

20+
import {AttributesTreeValue} from './attributesTreeValue';
21+
2422
const MAX_TREE_DEPTH = 4;
2523
const INVALID_BRANCH_REGEX = /\.{2,}/;
2624

@@ -59,7 +57,7 @@ export type AttributesFieldRendererProps<RendererExtra extends RenderFunctionBag
5957
meta?: EventsMetaType;
6058
};
6159

62-
interface AttributesFieldRender<RendererExtra extends RenderFunctionBaggage> {
60+
export interface AttributesFieldRender<RendererExtra extends RenderFunctionBaggage> {
6361
/**
6462
* Extra data that gets passed to the renderer function for every attribute in the tree. If any of your field renderers rely on data that isn't related to the attributes (e.g., the current theme or location) or data that lives in another attribute (e.g., using the log level attribute to render the log text attribute) you should pass that data as here.
6563
*/
@@ -88,7 +86,7 @@ interface AttributesTreeColumnsProps<RendererExtra extends RenderFunctionBaggage
8886
columnCount: number;
8987
}
9088

91-
interface AttributesTreeRowConfig {
89+
export interface AttributesTreeRowConfig {
9290
// Omits the dropdown of actions applicable to this attribute
9391
disableActions?: boolean;
9492
// Omit error styling from being displayed, even if context is invalid
@@ -444,54 +442,6 @@ function AttributesTreeRowDropdown({
444442
);
445443
}
446444

447-
function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>({
448-
config,
449-
content,
450-
renderers = {},
451-
rendererExtra: renderExtra,
452-
}: {
453-
content: AttributesTreeContent;
454-
config?: AttributesTreeRowConfig;
455-
} & AttributesFieldRender<RendererExtra> & {theme: Theme}) {
456-
const {originalAttribute} = content;
457-
if (!originalAttribute) {
458-
return null;
459-
}
460-
461-
// Check if we have a custom renderer for this attribute
462-
const attributeKey = originalAttribute.original_attribute_key;
463-
const renderer = renderers[attributeKey];
464-
465-
const defaultValue = <span>{String(content.value)}</span>;
466-
467-
if (config?.disableRichValue) {
468-
return String(content.value);
469-
}
470-
471-
if (renderer) {
472-
return renderer({
473-
item: getAttributeItem(attributeKey, content.value),
474-
basicRendered: defaultValue,
475-
extra: renderExtra,
476-
});
477-
}
478-
479-
return isUrl(String(content.value)) ? (
480-
<AttributeLinkText>
481-
<ExternalLink
482-
onClick={e => {
483-
e.preventDefault();
484-
openNavigateToExternalLinkModal({linkText: String(content.value)});
485-
}}
486-
>
487-
{defaultValue}
488-
</ExternalLink>
489-
</AttributeLinkText>
490-
) : (
491-
defaultValue
492-
);
493-
}
494-
495445
/**
496446
* Replaces sentry. prefixed keys, and simplifies the value
497447
*/
@@ -640,17 +590,3 @@ const TreeValueDropdown = styled(DropdownMenu)`
640590
z-index: 0;
641591
}
642592
`;
643-
644-
const AttributeLinkText = styled('span')`
645-
color: ${p => p.theme.linkColor};
646-
text-decoration: ${p => p.theme.linkUnderline} underline dotted;
647-
margin: 0;
648-
&:hover,
649-
&:focus {
650-
text-decoration: none;
651-
}
652-
653-
div {
654-
white-space: normal;
655-
}
656-
`;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {LocationFixture} from 'sentry-fixture/locationFixture';
2+
import {OrganizationFixture} from 'sentry-fixture/organization';
3+
import {ThemeFixture} from 'sentry-fixture/theme';
4+
5+
import {render, screen} from 'sentry-test/reactTestingLibrary';
6+
7+
import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
8+
import {AttributesTreeValue} from 'sentry/views/explore/components/traceItemAttributes/attributesTreeValue';
9+
10+
jest.mock('sentry/actionCreators/modal', () => ({
11+
openNavigateToExternalLinkModal: jest.fn(),
12+
}));
13+
14+
describe('AttributesTreeValue', function () {
15+
const organization = OrganizationFixture();
16+
const location = LocationFixture();
17+
const theme = ThemeFixture();
18+
19+
const defaultProps = {
20+
content: {
21+
subtree: {},
22+
value: 'test-value',
23+
originalAttribute: {
24+
attribute_key: 'test.key',
25+
attribute_value: 'test-value',
26+
original_attribute_key: 'test.key',
27+
},
28+
},
29+
rendererExtra: {
30+
organization,
31+
location,
32+
theme,
33+
},
34+
theme,
35+
};
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
});
40+
41+
it('returns null when originalAttribute is missing', function () {
42+
const {container} = render(
43+
<AttributesTreeValue
44+
{...defaultProps}
45+
content={{
46+
subtree: {},
47+
value: 'test-value',
48+
originalAttribute: undefined,
49+
}}
50+
/>
51+
);
52+
53+
expect(container).toBeEmptyDOMElement();
54+
});
55+
56+
it('renders with custom renderer when available', function () {
57+
const customRenderer = () => <div>Custom Rendered Content</div>;
58+
const renderers = {
59+
'test.key': customRenderer,
60+
};
61+
62+
render(<AttributesTreeValue {...defaultProps} renderers={renderers} />);
63+
64+
expect(screen.getByText('Custom Rendered Content')).toBeInTheDocument();
65+
expect(screen.queryByText('test-value')).not.toBeInTheDocument();
66+
});
67+
68+
it('renders URL value as a link with correct destination', function () {
69+
const urlContent = {
70+
...defaultProps.content,
71+
value: 'https://example.com',
72+
};
73+
74+
render(<AttributesTreeValue {...defaultProps} content={urlContent} />);
75+
76+
const link = screen.getByText('https://example.com').closest('a');
77+
expect(link).toBeInTheDocument();
78+
expect(link).toHaveAttribute('target', '_blank');
79+
expect(link).toHaveAttribute('rel', 'noreferrer noopener');
80+
});
81+
82+
it('renders URL value as plain string when rich values are disabled', function () {
83+
const urlContent = {
84+
...defaultProps.content,
85+
value: 'https://example.com',
86+
};
87+
88+
render(
89+
<AttributesTreeValue
90+
{...defaultProps}
91+
content={urlContent}
92+
config={{disableRichValue: true}}
93+
/>
94+
);
95+
96+
expect(screen.getByText('https://example.com')).toBeInTheDocument();
97+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
98+
});
99+
100+
it('calls openNavigateToExternalLinkModal when URL link is clicked', function () {
101+
const urlContent = {
102+
...defaultProps.content,
103+
value: 'https://example.com',
104+
};
105+
106+
render(<AttributesTreeValue {...defaultProps} content={urlContent} />);
107+
108+
const $link = screen.getByText('https://example.com').closest('a')!;
109+
$link.click();
110+
111+
expect(openNavigateToExternalLinkModal).toHaveBeenCalledWith({
112+
linkText: 'https://example.com',
113+
});
114+
});
115+
116+
it('renders non-URL values as plain spans', function () {
117+
render(<AttributesTreeValue {...defaultProps} />);
118+
119+
expect(screen.getByText('test-value')).toBeInTheDocument();
120+
});
121+
122+
it('handles null values correctly', function () {
123+
const nullContent = {
124+
...defaultProps.content,
125+
value: null,
126+
};
127+
128+
render(<AttributesTreeValue {...defaultProps} content={nullContent} />);
129+
130+
expect(screen.getByText('null')).toBeInTheDocument();
131+
});
132+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {type Theme} from '@emotion/react';
2+
import styled from '@emotion/styled';
3+
4+
import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
5+
import ExternalLink from 'sentry/components/links/externalLink';
6+
import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
7+
import {isUrl} from 'sentry/utils/string/isUrl';
8+
import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils';
9+
10+
import type {
11+
AttributesFieldRender,
12+
AttributesTreeContent,
13+
AttributesTreeRowConfig,
14+
} from './attributesTree';
15+
16+
export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>({
17+
config,
18+
content,
19+
renderers = {},
20+
rendererExtra: renderExtra,
21+
}: {
22+
content: AttributesTreeContent;
23+
config?: AttributesTreeRowConfig;
24+
} & AttributesFieldRender<RendererExtra> & {theme: Theme}) {
25+
const {originalAttribute} = content;
26+
if (!originalAttribute) {
27+
return null;
28+
}
29+
30+
// Check if we have a custom renderer for this attribute
31+
const attributeKey = originalAttribute.original_attribute_key;
32+
const renderer = renderers[attributeKey];
33+
34+
const defaultValue = <span>{String(content.value)}</span>;
35+
36+
if (config?.disableRichValue) {
37+
return String(content.value);
38+
}
39+
40+
if (renderer) {
41+
return renderer({
42+
item: getAttributeItem(attributeKey, content.value),
43+
basicRendered: defaultValue,
44+
extra: renderExtra,
45+
});
46+
}
47+
48+
return isUrl(String(content.value)) ? (
49+
<AttributeLinkText>
50+
<ExternalLink
51+
onClick={e => {
52+
e.preventDefault();
53+
openNavigateToExternalLinkModal({linkText: String(content.value)});
54+
}}
55+
>
56+
{defaultValue}
57+
</ExternalLink>
58+
</AttributeLinkText>
59+
) : (
60+
defaultValue
61+
);
62+
}
63+
64+
const AttributeLinkText = styled('span')`
65+
color: ${p => p.theme.linkColor};
66+
text-decoration: ${p => p.theme.linkUnderline} underline dotted;
67+
margin: 0;
68+
&:hover,
69+
&:focus {
70+
text-decoration: none;
71+
}
72+
73+
div {
74+
white-space: normal;
75+
}
76+
`;

0 commit comments

Comments
 (0)