Skip to content

Commit 1619a77

Browse files
feat(issues/flags): add ui for feature flag table in event details (#77218)
### Feature Flag UI - Adds a feature flag UI component in event details (under a feature flag, only visible to sentry orgs) showing a list of the organization's evaluated feature flags. This component sits below the event contexts. - The default sort order is evaluation order. The component shows a max of 10 rows & 2 columns. - `View All` opens up a drawer similar to the breadcrumbs drawer, showing all feature flags w/ ability to sort & search. ### Followup - Will add a feedback button in a followup PR ([ticket](#77399)) - Will add analytics in a followup PR With real data: ![SCR-20240912-kpjn](https://github.com/user-attachments/assets/50f064d5-ea21-482a-aad4-a2b7c0051266) With more robust fake data: https://github.com/user-attachments/assets/f4556288-7b13-4afc-88de-df15a3999830 With 1 column of flags: <img width="1009" alt="SCR-20240912-jjct" src="https://github.com/user-attachments/assets/641f0851-2f68-4af3-af36-ccb47c4d7775"> - [Figma](https://www.figma.com/design/oTVOd3RGUSZWYGwTpGjeGK/Specs%3A-Feature-Flag-List-in-Issue-Details?node-id=212-1627&node-type=canvas&t=DGQBOPYIbvPzXy2R-0) - Relates to #77160
1 parent 39b533c commit 1619a77

File tree

6 files changed

+416
-77
lines changed

6 files changed

+416
-77
lines changed

static/app/components/events/breadcrumbs/breadcrumbsDrawer.tsx

Lines changed: 19 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {useTheme} from '@emotion/react';
33
import styled from '@emotion/styled';
44

55
import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
6-
import {Breadcrumbs as NavigationBreadcrumbs} from 'sentry/components/breadcrumbs';
76
import {Button} from 'sentry/components/button';
87
import ButtonBar from 'sentry/components/buttonBar';
98
import {CompactSelect} from 'sentry/components/compactSelect';
@@ -15,13 +14,23 @@ import {
1514
type EnhancedCrumb,
1615
useBreadcrumbFilters,
1716
} from 'sentry/components/events/breadcrumbs/utils';
17+
import {
18+
CrumbContainer,
19+
EventDrawerBody,
20+
EventDrawerContainer,
21+
EventDrawerHeader,
22+
EventNavigator,
23+
Header,
24+
NavigationCrumbs,
25+
SearchInput,
26+
ShortId,
27+
} from 'sentry/components/events/eventReplay/eventDrawer';
1828
import {
1929
applyBreadcrumbSearch,
2030
BREADCRUMB_SORT_LOCALSTORAGE_KEY,
2131
BREADCRUMB_SORT_OPTIONS,
2232
BreadcrumbSort,
2333
} from 'sentry/components/events/interfaces/breadcrumbs';
24-
import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
2534
import {InputGroup} from 'sentry/components/inputGroup';
2635
import {IconClock, IconFilter, IconSearch, IconSort, IconTimer} from 'sentry/icons';
2736
import {t} from 'sentry/locale';
@@ -33,7 +42,6 @@ import {trackAnalytics} from 'sentry/utils/analytics';
3342
import {getShortEventId} from 'sentry/utils/events';
3443
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
3544
import useOrganization from 'sentry/utils/useOrganization';
36-
import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventNavigation';
3745

3846
export const enum BreadcrumbControlOptions {
3947
SEARCH = 'search',
@@ -207,8 +215,8 @@ export function BreadcrumbsDrawer({
207215
);
208216

209217
return (
210-
<BreadcrumbDrawerContainer>
211-
<BreadcrumbDrawerHeader>
218+
<EventDrawerContainer>
219+
<EventDrawerHeader>
212220
<NavigationCrumbs
213221
crumbs={[
214222
{
@@ -223,12 +231,12 @@ export function BreadcrumbsDrawer({
223231
{label: t('Breadcrumbs')},
224232
]}
225233
/>
226-
</BreadcrumbDrawerHeader>
227-
<BreadcrumbNavigator>
234+
</EventDrawerHeader>
235+
<EventNavigator>
228236
<Header>{t('Breadcrumbs')}</Header>
229237
{actions}
230-
</BreadcrumbNavigator>
231-
<BreadcrumbDrawerBody ref={setContainer}>
238+
</EventNavigator>
239+
<EventDrawerBody ref={setContainer}>
232240
<TimelineContainer>
233241
{displayCrumbs.length === 0 ? (
234242
<EmptyMessage>
@@ -255,8 +263,8 @@ export function BreadcrumbsDrawer({
255263
/>
256264
)}
257265
</TimelineContainer>
258-
</BreadcrumbDrawerBody>
259-
</BreadcrumbDrawerContainer>
266+
</EventDrawerBody>
267+
</EventDrawerContainer>
260268
);
261269
}
262270

@@ -265,55 +273,6 @@ const VisibleFocusButton = styled(Button)`
265273
0 0 1px;
266274
`;
267275

268-
const BreadcrumbDrawerContainer = styled('div')`
269-
height: 100%;
270-
display: grid;
271-
grid-template-rows: auto auto 1fr;
272-
`;
273-
274-
const BreadcrumbDrawerHeader = styled(DrawerHeader)`
275-
position: unset;
276-
max-height: ${MIN_NAV_HEIGHT}px;
277-
box-shadow: none;
278-
border-bottom: 1px solid ${p => p.theme.border};
279-
`;
280-
281-
const BreadcrumbNavigator = styled('div')`
282-
display: grid;
283-
grid-template-columns: 1fr auto;
284-
align-items: center;
285-
column-gap: ${space(1)};
286-
padding: ${space(0.75)} 24px;
287-
background: ${p => p.theme.background};
288-
z-index: 1;
289-
min-height: ${MIN_NAV_HEIGHT}px;
290-
box-shadow: ${p => p.theme.translucentBorder} 0 1px;
291-
`;
292-
293-
const BreadcrumbDrawerBody = styled(DrawerBody)`
294-
overflow: auto;
295-
overscroll-behavior: contain;
296-
/* Move the scrollbar to the left edge */
297-
scroll-margin: 0 ${space(2)};
298-
direction: rtl;
299-
* {
300-
direction: ltr;
301-
}
302-
`;
303-
304-
const Header = styled('h3')`
305-
display: block;
306-
font-size: ${p => p.theme.fontSizeExtraLarge};
307-
font-weight: ${p => p.theme.fontWeightBold};
308-
margin: 0;
309-
`;
310-
311-
const SearchInput = styled(InputGroup.Input)`
312-
border: 0;
313-
box-shadow: unset;
314-
color: inherit;
315-
`;
316-
317276
const TimelineContainer = styled('div')`
318277
grid-column: span 2;
319278
`;
@@ -326,20 +285,3 @@ const EmptyMessage = styled('div')`
326285
color: ${p => p.theme.subText};
327286
padding: ${space(3)} ${space(1)};
328287
`;
329-
330-
const NavigationCrumbs = styled(NavigationBreadcrumbs)`
331-
margin: 0;
332-
padding: 0;
333-
`;
334-
335-
const CrumbContainer = styled('div')`
336-
display: flex;
337-
gap: ${space(1)};
338-
align-items: center;
339-
`;
340-
341-
const ShortId = styled('div')`
342-
font-family: ${p => p.theme.text.family};
343-
font-size: ${p => p.theme.fontSizeMedium};
344-
line-height: 1;
345-
`;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Breadcrumbs as NavigationBreadcrumbs} from 'sentry/components/breadcrumbs';
4+
import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
5+
import {InputGroup} from 'sentry/components/inputGroup';
6+
import {space} from 'sentry/styles/space';
7+
import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventNavigation';
8+
9+
export const Header = styled('h3')`
10+
display: block;
11+
font-size: ${p => p.theme.fontSizeExtraLarge};
12+
font-weight: ${p => p.theme.fontWeightBold};
13+
margin: 0;
14+
`;
15+
16+
export const SearchInput = styled(InputGroup.Input)`
17+
border: 0;
18+
box-shadow: unset;
19+
color: inherit;
20+
`;
21+
22+
export const NavigationCrumbs = styled(NavigationBreadcrumbs)`
23+
margin: 0;
24+
padding: 0;
25+
`;
26+
27+
export const CrumbContainer = styled('div')`
28+
display: flex;
29+
gap: ${space(1)};
30+
align-items: center;
31+
`;
32+
33+
export const ShortId = styled('div')`
34+
font-family: ${p => p.theme.text.family};
35+
font-size: ${p => p.theme.fontSizeMedium};
36+
line-height: 1;
37+
`;
38+
39+
export const EventDrawerContainer = styled('div')`
40+
height: 100%;
41+
display: grid;
42+
grid-template-rows: auto auto 1fr;
43+
`;
44+
45+
export const EventDrawerHeader = styled(DrawerHeader)`
46+
position: unset;
47+
max-height: ${MIN_NAV_HEIGHT}px;
48+
box-shadow: none;
49+
border-bottom: 1px solid ${p => p.theme.border};
50+
`;
51+
52+
export const EventNavigator = styled('div')`
53+
display: grid;
54+
grid-template-columns: 1fr auto;
55+
align-items: center;
56+
column-gap: ${space(1)};
57+
padding: ${space(0.75)} 24px;
58+
background: ${p => p.theme.background};
59+
z-index: 1;
60+
min-height: ${MIN_NAV_HEIGHT}px;
61+
box-shadow: ${p => p.theme.translucentBorder} 0 1px;
62+
`;
63+
64+
export const EventDrawerBody = styled(DrawerBody)`
65+
overflow: auto;
66+
overscroll-behavior: contain;
67+
/* Move the scrollbar to the left edge */
68+
scroll-margin: 0 ${space(2)};
69+
direction: rtl;
70+
* {
71+
direction: ltr;
72+
}
73+
`;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {useCallback, useMemo, useRef, useState} from 'react';
2+
3+
import {Button} from 'sentry/components/button';
4+
import ButtonBar from 'sentry/components/buttonBar';
5+
import {CompactSelect} from 'sentry/components/compactSelect';
6+
import DropdownButton from 'sentry/components/dropdownButton';
7+
import ErrorBoundary from 'sentry/components/errorBoundary';
8+
import {
9+
CardContainer,
10+
FeatureFlagDrawer,
11+
FLAG_SORT_OPTIONS,
12+
FlagSort,
13+
getLabel,
14+
} from 'sentry/components/events/featureFlags/featureFlagDrawer';
15+
import useDrawer from 'sentry/components/globalDrawer';
16+
import KeyValueData, {
17+
type KeyValueDataContentProps,
18+
} from 'sentry/components/keyValueData';
19+
import {IconSort} from 'sentry/icons';
20+
import {t} from 'sentry/locale';
21+
import type {Event, FeatureFlag} from 'sentry/types/event';
22+
import type {Group} from 'sentry/types/group';
23+
import type {Project} from 'sentry/types/project';
24+
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
25+
26+
export function EventFeatureFlagList({
27+
event,
28+
group,
29+
project,
30+
}: {
31+
event: Event;
32+
group: Group;
33+
project: Project;
34+
}) {
35+
const [sortMethod, setSortMethod] = useState<FlagSort>(FlagSort.EVAL);
36+
const {closeDrawer, isDrawerOpen, openDrawer} = useDrawer();
37+
const viewAllButtonRef = useRef<HTMLButtonElement>(null);
38+
39+
// Transform the flags array into something readable by the key-value component
40+
const hydrateFlags = (flags: FeatureFlag[] | undefined): KeyValueDataContentProps[] => {
41+
if (!flags) {
42+
return [];
43+
}
44+
return flags.map(f => {
45+
return {
46+
item: {key: f.flag, subject: f.flag, value: f.result.toString()},
47+
};
48+
});
49+
};
50+
51+
// Remove duplicates
52+
const hydratedFlags = useMemo(
53+
() => hydrateFlags(event.contexts?.flags?.values),
54+
[event]
55+
);
56+
57+
const handleSortAlphabetical = (flags: KeyValueDataContentProps[]) => {
58+
return [...flags].sort((a, b) => {
59+
return a.item.key.localeCompare(b.item.key);
60+
});
61+
};
62+
63+
const sortedFlags =
64+
sortMethod === FlagSort.ALPHA ? handleSortAlphabetical(hydratedFlags) : hydratedFlags;
65+
66+
const onViewAllFlags = useCallback(() => {
67+
openDrawer(
68+
() => (
69+
<FeatureFlagDrawer
70+
group={group}
71+
event={event}
72+
project={project}
73+
hydratedFlags={hydratedFlags}
74+
initialSort={sortMethod}
75+
/>
76+
),
77+
{
78+
ariaLabel: t('Feature flags drawer'),
79+
// We prevent a click on the 'View All' button from closing the drawer so that
80+
// we don't reopen it immediately, and instead let the button handle this itself.
81+
shouldCloseOnInteractOutside: element => {
82+
const viewAllButton = viewAllButtonRef.current;
83+
if (viewAllButton?.contains(element)) {
84+
return false;
85+
}
86+
return true;
87+
},
88+
transitionProps: {stiffness: 1000},
89+
}
90+
);
91+
}, [openDrawer, event, group, project, sortMethod, hydratedFlags]);
92+
93+
if (!hydratedFlags.length) {
94+
return null;
95+
}
96+
97+
const actions = (
98+
<ButtonBar gap={1}>
99+
<Button
100+
size="xs"
101+
aria-label={t('View All')}
102+
ref={viewAllButtonRef}
103+
onClick={() => {
104+
isDrawerOpen ? closeDrawer() : onViewAllFlags();
105+
}}
106+
>
107+
{t('View All')}
108+
</Button>
109+
<CompactSelect
110+
value={sortMethod}
111+
options={FLAG_SORT_OPTIONS}
112+
triggerProps={{
113+
'aria-label': t('Sort Flags'),
114+
}}
115+
onChange={selection => {
116+
setSortMethod(selection.value);
117+
}}
118+
trigger={triggerProps => (
119+
<DropdownButton {...triggerProps} size="xs" icon={<IconSort />}>
120+
{getLabel(sortMethod)}
121+
</DropdownButton>
122+
)}
123+
/>
124+
</ButtonBar>
125+
);
126+
127+
// Split the flags list into two columns for display
128+
const truncatedItems = sortedFlags.slice(0, 20);
129+
const columnOne = truncatedItems.slice(0, 10);
130+
let columnTwo: typeof truncatedItems = [];
131+
if (truncatedItems.length > 10) {
132+
columnTwo = truncatedItems.slice(10, 20);
133+
}
134+
135+
return (
136+
<ErrorBoundary mini message={t('There was a problem loading feature flags.')}>
137+
<InterimSection title={t('Feature Flags')} type="feature-flags" actions={actions}>
138+
<CardContainer numCols={columnTwo.length ? 2 : 1}>
139+
<KeyValueData.Card contentItems={columnOne} />
140+
<KeyValueData.Card contentItems={columnTwo} />
141+
</CardContainer>
142+
</InterimSection>
143+
</ErrorBoundary>
144+
);
145+
}

0 commit comments

Comments
 (0)