Skip to content

Commit 530e806

Browse files
authored
feat(issue-details): Revise Context UI behind feature flag (#68081)
Requires #68530 This PR changes the interface for displaying context items on the issue details page behind the `event-tags-tree-ui` flag. Requires `https://github.com/getsentry/sentry/pull/68530` to create an easy interface to access the formatted context data outside of the `ContextBlock` components. The masonry layout will be deferred for now to focus on highlights a bit more. **Before** ![image](https://github.com/getsentry/sentry/assets/35509934/03d90b2e-624e-49d7-b71e-e54a205857e4) **After** ![image](https://github.com/getsentry/sentry/assets/35509934/3f2622c7-af93-48c4-9de8-36e969af3210) **todo** - [x] Add tests - [x] Add screenshots - [x] Double check + screenshots for partial redaction - [x] Double check + screenshots for data scrubbing rules as 'error state' **update** Okay, was able to double check masking and extremely large values and made some small changes for formatting. Advanced data scrubbing still still shows up with UX friendly tooltips, and I think it's okay they don't show up as errors since they're expected by the organization. ![image](https://github.com/getsentry/sentry/assets/35509934/d824b1fb-8f3e-452f-a358-1692f7d10e35)
1 parent ad31226 commit 530e806

File tree

14 files changed

+445
-28
lines changed

14 files changed

+445
-28
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import startCase from 'lodash/startCase';
2+
import {EventFixture} from 'sentry-fixture/event';
3+
import {GroupFixture} from 'sentry-fixture/group';
4+
5+
import {initializeOrg} from 'sentry-test/initializeOrg';
6+
import {render, screen} from 'sentry-test/reactTestingLibrary';
7+
8+
import ContextCard from 'sentry/components/events/contexts/contextCard';
9+
10+
describe('ContextCard', function () {
11+
it('renders the card with formatted context data', function () {
12+
const event = EventFixture();
13+
const group = GroupFixture();
14+
const {project} = initializeOrg();
15+
const alias = 'Things in my Vicinity';
16+
const simpleContext = {
17+
snack: 'peanut',
18+
dinner: 'rice',
19+
friend: 'noelle',
20+
};
21+
const structuredContext = {
22+
'my dogs': ['cocoa', 'butter'],
23+
book: {
24+
title: 'This Is How You Lose the Time War',
25+
pages: 208,
26+
published: '2018-07-21T00:00:00.000Z',
27+
},
28+
};
29+
const customContext = {
30+
...simpleContext,
31+
...structuredContext,
32+
type: 'default',
33+
};
34+
render(
35+
<ContextCard
36+
type="default"
37+
alias={alias}
38+
value={customContext}
39+
event={event}
40+
group={group}
41+
project={project}
42+
/>
43+
);
44+
45+
expect(screen.getByText(startCase(alias))).toBeInTheDocument();
46+
Object.entries(simpleContext).forEach(([key, value]) => {
47+
expect(screen.getByText(key)).toBeInTheDocument();
48+
expect(screen.getByText(value)).toBeInTheDocument();
49+
});
50+
Object.entries(structuredContext).forEach(([key, value]) => {
51+
expect(screen.getByText(key)).toBeInTheDocument();
52+
expect(
53+
screen.getByText(`${Object.values(value).length} items`)
54+
).toBeInTheDocument();
55+
});
56+
});
57+
58+
it('renders the annotated text and errors', function () {
59+
const alias = 'broken';
60+
const event = EventFixture({
61+
_meta: {
62+
contexts: {
63+
default: {
64+
error: {
65+
'': {
66+
err: [
67+
[
68+
'invalid_data',
69+
{
70+
reason: 'expected something better',
71+
},
72+
],
73+
],
74+
val: 'worse',
75+
},
76+
},
77+
redacted: {
78+
'': {
79+
chunks: [
80+
{
81+
remark: 'x',
82+
rule_id: 'project:0',
83+
text: '',
84+
type: 'redaction',
85+
},
86+
],
87+
len: 9,
88+
rem: [['project:0', 'x', 0, 0]],
89+
},
90+
},
91+
},
92+
},
93+
},
94+
});
95+
const group = GroupFixture();
96+
const {project} = initializeOrg();
97+
const errorContext = {
98+
error: '',
99+
redacted: '',
100+
type: 'default',
101+
};
102+
103+
render(
104+
<ContextCard
105+
type="default"
106+
alias={alias}
107+
value={errorContext}
108+
event={event}
109+
group={group}
110+
project={project}
111+
/>
112+
);
113+
114+
expect(screen.getByText('<invalid>')).toBeInTheDocument();
115+
expect(screen.getByTestId('annotated-text-error-icon')).toBeInTheDocument();
116+
expect(screen.getByText('<redacted>')).toBeInTheDocument();
117+
});
118+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {Link} from 'react-router';
2+
import styled from '@emotion/styled';
3+
4+
import {
5+
getContextMeta,
6+
getContextTitle,
7+
getFormattedContextData,
8+
} from 'sentry/components/events/contexts/utils';
9+
import {AnnotatedTextErrors} from 'sentry/components/events/meta/annotatedText/annotatedTextErrors';
10+
import Panel from 'sentry/components/panels/panel';
11+
import {StructuredData} from 'sentry/components/structuredEventData';
12+
import {space} from 'sentry/styles/space';
13+
import type {Group, Project} from 'sentry/types';
14+
import type {Event} from 'sentry/types/event';
15+
import {defined, objectIsEmpty} from 'sentry/utils';
16+
import useOrganization from 'sentry/utils/useOrganization';
17+
18+
interface ContextCardProps {
19+
alias: string;
20+
event: Event;
21+
type: string;
22+
group?: Group;
23+
project?: Project;
24+
value?: Record<string, any>;
25+
}
26+
27+
function ContextCard({alias, event, type, project, value = {}}: ContextCardProps) {
28+
const organization = useOrganization();
29+
if (objectIsEmpty(value)) {
30+
return null;
31+
}
32+
const meta = getContextMeta(event, type);
33+
34+
const contextItems = getFormattedContextData({
35+
event,
36+
contextValue: value,
37+
contextType: type,
38+
organization,
39+
project,
40+
});
41+
42+
const content = contextItems.map(
43+
({key, subject, value: contextValue, action = {}}, i) => {
44+
if (key === 'type') {
45+
return null;
46+
}
47+
const contextMeta = meta?.[key];
48+
const contextErrors = contextMeta?.['']?.err ?? [];
49+
const hasErrors = contextErrors.length > 0;
50+
51+
const dataComponent = (
52+
<StructuredData
53+
value={contextValue}
54+
depth={0}
55+
maxDefaultDepth={0}
56+
meta={contextMeta}
57+
withAnnotatedText
58+
withOnlyFormattedText
59+
/>
60+
);
61+
62+
return (
63+
<ContextContent key={i} hasErrors={hasErrors}>
64+
<ContextSubject>{subject}</ContextSubject>
65+
<ContextValue hasErrors={hasErrors}>
66+
{defined(action?.link) ? (
67+
<Link to={action.link}>{dataComponent}</Link>
68+
) : (
69+
dataComponent
70+
)}
71+
</ContextValue>
72+
<ContextErrors>
73+
<AnnotatedTextErrors errors={contextErrors} />
74+
</ContextErrors>
75+
</ContextContent>
76+
);
77+
}
78+
);
79+
80+
return (
81+
<Card>
82+
<ContextTitle>{getContextTitle({alias, type, value})}</ContextTitle>
83+
{content}
84+
</Card>
85+
);
86+
}
87+
88+
const Card = styled(Panel)`
89+
padding: ${space(0.75)};
90+
display: grid;
91+
column-gap: ${space(1.5)};
92+
grid-template-columns: minmax(100px, auto) 1fr 30px;
93+
font-size: ${p => p.theme.fontSizeSmall};
94+
`;
95+
96+
const ContextTitle = styled('p')`
97+
grid-column: span 2;
98+
padding: ${space(0.25)} ${space(0.75)};
99+
margin: 0;
100+
color: ${p => p.theme.headingColor};
101+
font-weight: bold;
102+
`;
103+
104+
const ContextContent = styled('div')<{hasErrors: boolean}>`
105+
display: grid;
106+
grid-template-columns: subgrid;
107+
grid-column: span 3;
108+
column-gap: ${space(1.5)};
109+
padding: ${space(0.25)} ${space(0.75)};
110+
border-radius: 4px;
111+
color: ${p => (p.hasErrors ? p.theme.alert.error.color : p.theme.subText)};
112+
border: 1px solid ${p => (p.hasErrors ? p.theme.alert.error.border : 'transparent')};
113+
background-color: ${p =>
114+
p.hasErrors ? p.theme.alert.error.backgroundLight : p.theme.background};
115+
&:nth-child(odd) {
116+
background-color: ${p =>
117+
p.hasErrors ? p.theme.alert.error.backgroundLight : p.theme.backgroundSecondary};
118+
}
119+
`;
120+
121+
const ContextSubject = styled('div')`
122+
grid-column: span 1;
123+
font-family: ${p => p.theme.text.familyMono};
124+
word-wrap: break-word;
125+
`;
126+
127+
const ContextValue = styled(ContextSubject)<{hasErrors: boolean}>`
128+
color: ${p => (p.hasErrors ? 'inherit' : p.theme.textColor)};
129+
grid-column: span ${p => (p.hasErrors ? 1 : 2)};
130+
/* justify-content: space-between;
131+
display: inline-flex; */
132+
`;
133+
134+
const ContextErrors = styled('div')``;
135+
136+
export default ContextCard;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {useRef} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {CONTEXT_DOCS_LINK} from 'sentry/components/events/contextSummary/utils';
5+
import {EventDataSection} from 'sentry/components/events/eventDataSection';
6+
import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
7+
import ExternalLink from 'sentry/components/links/externalLink';
8+
import {t, tct} from 'sentry/locale';
9+
10+
interface ContextDataSectionProps {
11+
cards: React.ReactNode[];
12+
}
13+
14+
function ContextDataSection({cards}: ContextDataSectionProps) {
15+
const containerRef = useRef<HTMLDivElement>(null);
16+
const columnCount = useIssueDetailsColumnCount(containerRef);
17+
const columns: React.ReactNode[] = [];
18+
const columnSize = Math.ceil(cards.length / columnCount);
19+
for (let i = 0; i < cards.length; i += columnSize) {
20+
columns.push(<CardColumn key={i}>{cards.slice(i, i + columnSize)}</CardColumn>);
21+
}
22+
return (
23+
<EventDataSection
24+
key={'context'}
25+
type={'context'}
26+
title={t('Context')}
27+
help={tct(
28+
'The structured context items attached to this event. [link:Learn more]',
29+
{
30+
link: <ExternalLink openInNewTab href={CONTEXT_DOCS_LINK} />,
31+
}
32+
)}
33+
isHelpHoverable
34+
>
35+
<CardWrapper columnCount={columnCount} ref={containerRef}>
36+
{columns}
37+
</CardWrapper>
38+
</EventDataSection>
39+
);
40+
}
41+
42+
const CardWrapper = styled('div')<{columnCount: number}>`
43+
display: grid;
44+
align-items: start;
45+
grid-template-columns: repeat(${p => p.columnCount}, 1fr);
46+
gap: 10px;
47+
`;
48+
49+
const CardColumn = styled('div')`
50+
grid-column: span 1;
51+
`;
52+
53+
export default ContextDataSection;

static/app/components/events/contexts/index.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import {Fragment, useCallback, useEffect} from 'react';
22
import * as Sentry from '@sentry/react';
33

4+
import ContextCard from 'sentry/components/events/contexts/contextCard';
5+
import ContextDataSection from 'sentry/components/events/contexts/contextDataSection';
6+
import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util';
47
import type {Group} from 'sentry/types';
58
import type {Event} from 'sentry/types/event';
69
import {objectIsEmpty} from 'sentry/utils';
10+
import useProjects from 'sentry/utils/useProjects';
711

812
import {Chunk} from './chunk';
913

@@ -13,6 +17,9 @@ type Props = {
1317
};
1418

1519
export function EventContexts({event, group}: Props) {
20+
const hasNewTagsUI = useHasNewTagsUI();
21+
const {projects} = useProjects();
22+
const project = projects.find(p => p.id === event.projectID);
1623
const {user, contexts, sdk} = event;
1724

1825
const {feedback, response, ...otherContexts} = contexts ?? {};
@@ -31,6 +38,40 @@ export function EventContexts({event, group}: Props) {
3138
}
3239
}, [usingOtel, sdk]);
3340

41+
if (hasNewTagsUI) {
42+
const orderedContext: [string, any][] = [
43+
['response', response],
44+
['feedback', feedback],
45+
['user', user],
46+
...Object.entries(otherContexts),
47+
];
48+
// For these context keys, use 'key' as 'type' rather than 'value.type'
49+
const overrideTypes = new Set(['response', 'feedback', 'user']);
50+
const cards = orderedContext
51+
.filter(([_k, v]) => {
52+
const contextKeys = Object.keys(v ?? {});
53+
const isInvalid =
54+
// Empty context
55+
contextKeys.length === 0 ||
56+
// Empty aside from 'type' key
57+
(contextKeys.length === 1 && contextKeys[0] === 'type');
58+
return !isInvalid;
59+
})
60+
.map(([k, v]) => (
61+
<ContextCard
62+
key={k}
63+
type={overrideTypes.has(k) ? k : v?.type ?? ''}
64+
alias={k}
65+
value={v}
66+
event={event}
67+
group={group}
68+
project={project}
69+
/>
70+
));
71+
72+
return <ContextDataSection cards={cards} />;
73+
}
74+
3475
return (
3576
<Fragment>
3677
{!objectIsEmpty(response) && (

static/app/components/events/contexts/profile/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ function getProfileKnownDataDetails({
113113
return {
114114
subject: t('Profile ID'),
115115
value: data.profile_id,
116+
action: {link: target},
116117
actionButton: target && (
117118
<Button
118119
size="xs"

0 commit comments

Comments
 (0)