groupTagValues.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import {Fragment} from 'react';
  2. import {RouteComponentProps, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import DataExport, {ExportQueryType} from 'sentry/components/dataExport';
  7. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  8. import {DeviceName} from 'sentry/components/deviceName';
  9. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  10. import GlobalSelectionLink from 'sentry/components/globalSelectionLink';
  11. import UserBadge from 'sentry/components/idBadge/userBadge';
  12. import * as Layout from 'sentry/components/layouts/thirds';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import Link from 'sentry/components/links/link';
  15. import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
  16. import Pagination from 'sentry/components/pagination';
  17. import PanelTable from 'sentry/components/panels/panelTable';
  18. import TimeSince from 'sentry/components/timeSince';
  19. import {IconArrow, IconEllipsis, IconMail, IconOpen} from 'sentry/icons';
  20. import {t} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import {
  23. Group,
  24. Organization,
  25. Project,
  26. SavedQueryVersions,
  27. Tag,
  28. TagValue,
  29. } from 'sentry/types';
  30. import {isUrl, percent} from 'sentry/utils';
  31. import EventView from 'sentry/utils/discover/eventView';
  32. import withOrganization from 'sentry/utils/withOrganization';
  33. // eslint-disable-next-line no-restricted-imports
  34. import withSentryRouter from 'sentry/utils/withSentryRouter';
  35. type RouteParams = {
  36. groupId: string;
  37. orgId: string;
  38. tagKey?: string;
  39. };
  40. type Props = {
  41. baseUrl: string;
  42. group: Group;
  43. organization: Organization;
  44. environments?: string[];
  45. project?: Project;
  46. } & RouteComponentProps<RouteParams, {}>;
  47. type State = {
  48. tag: Tag | null;
  49. tagValueList: TagValue[] | null;
  50. tagValueListPageLinks: string;
  51. };
  52. const DEFAULT_SORT = 'count';
  53. class GroupTagValues extends DeprecatedAsyncComponent<
  54. Props & DeprecatedAsyncComponent['props'] & WithRouterProps,
  55. State & DeprecatedAsyncComponent['state']
  56. > {
  57. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  58. const {environments: environment} = this.props;
  59. const {groupId, tagKey} = this.props.params;
  60. return [
  61. ['tag', `/issues/${groupId}/tags/${tagKey}/`],
  62. [
  63. 'tagValueList',
  64. `/issues/${groupId}/tags/${tagKey}/values/`,
  65. {query: {environment, sort: this.getSort()}},
  66. ],
  67. ];
  68. }
  69. getSort(): string {
  70. return this.props.location.query.sort || DEFAULT_SORT;
  71. }
  72. renderLoading() {
  73. return this.renderBody();
  74. }
  75. renderResults() {
  76. const {
  77. baseUrl,
  78. project,
  79. environments: environment,
  80. group,
  81. location: {query},
  82. params: {orgId, tagKey},
  83. organization,
  84. } = this.props;
  85. const {tagValueList, tag} = this.state;
  86. const discoverFields = [
  87. 'title',
  88. 'release',
  89. 'environment',
  90. 'user.display',
  91. 'timestamp',
  92. ];
  93. const globalSelectionParams = extractSelectionParameters(query);
  94. return tagValueList?.map((tagValue, tagValueIdx) => {
  95. const pct = tag?.totalValues
  96. ? `${percent(tagValue.count, tag?.totalValues).toFixed(2)}%`
  97. : '--';
  98. const key = tagValue.key ?? tagKey;
  99. const issuesQuery = tagValue.query || `${key}:"${tagValue.value}"`;
  100. const discoverView = EventView.fromSavedQuery({
  101. id: undefined,
  102. name: key ?? '',
  103. fields: [
  104. ...(key !== undefined ? [key] : []),
  105. ...discoverFields.filter(field => field !== key),
  106. ],
  107. orderby: '-timestamp',
  108. query: `issue:${group.shortId} ${issuesQuery}`,
  109. projects: [Number(project?.id)],
  110. environment,
  111. version: 2 as SavedQueryVersions,
  112. range: '90d',
  113. });
  114. const issuesPath = `/organizations/${orgId}/issues/`;
  115. return (
  116. <Fragment key={tagValueIdx}>
  117. <NameColumn>
  118. <NameWrapper data-test-id="group-tag-value">
  119. <GlobalSelectionLink
  120. to={{
  121. pathname: `${baseUrl}events/`,
  122. query: {query: issuesQuery},
  123. }}
  124. >
  125. {key === 'user' ? (
  126. <UserBadge
  127. user={{...tagValue, id: tagValue.identifier ?? ''}}
  128. avatarSize={20}
  129. hideEmail
  130. />
  131. ) : (
  132. <DeviceName value={tagValue.name} />
  133. )}
  134. </GlobalSelectionLink>
  135. </NameWrapper>
  136. {tagValue.email && (
  137. <StyledExternalLink
  138. href={`mailto:${tagValue.email}`}
  139. data-test-id="group-tag-mail"
  140. >
  141. <IconMail size="xs" color="gray300" />
  142. </StyledExternalLink>
  143. )}
  144. {isUrl(tagValue.value) && (
  145. <StyledExternalLink href={tagValue.value} data-test-id="group-tag-url">
  146. <IconOpen size="xs" color="gray300" />
  147. </StyledExternalLink>
  148. )}
  149. </NameColumn>
  150. <RightAlignColumn>{pct}</RightAlignColumn>
  151. <RightAlignColumn>{tagValue.count.toLocaleString()}</RightAlignColumn>
  152. <RightAlignColumn>
  153. <TimeSince date={tagValue.lastSeen} />
  154. </RightAlignColumn>
  155. <RightAlignColumn>
  156. <DropdownMenu
  157. size="sm"
  158. position="bottom-end"
  159. triggerProps={{
  160. size: 'xs',
  161. showChevron: false,
  162. icon: <IconEllipsis size="xs" />,
  163. 'aria-label': t('More'),
  164. }}
  165. items={[
  166. {
  167. key: 'open-in-discover',
  168. label: t('Open in Discover'),
  169. to: discoverView.getResultsViewUrlTarget(orgId),
  170. hidden: !organization.features.includes('discover-basic'),
  171. },
  172. {
  173. key: 'search-issues',
  174. label: t('Search All Issues with Tag Value'),
  175. to: {
  176. pathname: issuesPath,
  177. query: {
  178. ...globalSelectionParams, // preserve page filter selections
  179. query: issuesQuery,
  180. },
  181. },
  182. },
  183. ]}
  184. />
  185. </RightAlignColumn>
  186. </Fragment>
  187. );
  188. });
  189. }
  190. renderBody() {
  191. const {
  192. group,
  193. params: {orgId, tagKey},
  194. location: {query},
  195. environments,
  196. } = this.props;
  197. const {tagValueList, tag, tagValueListPageLinks, loading} = this.state;
  198. const {cursor: _cursor, page: _page, ...currentQuery} = query;
  199. const title = tagKey === 'user' ? t('Affected Users') : tagKey;
  200. const sort = this.getSort();
  201. const sortArrow = <IconArrow color="gray300" size="xs" direction="down" />;
  202. const lastSeenColumnHeader = (
  203. <StyledSortLink
  204. to={{
  205. pathname: location.pathname,
  206. query: {
  207. ...currentQuery,
  208. sort: 'date',
  209. },
  210. }}
  211. >
  212. {t('Last Seen')} {sort === 'date' && sortArrow}
  213. </StyledSortLink>
  214. );
  215. const countColumnHeader = (
  216. <StyledSortLink
  217. to={{
  218. pathname: location.pathname,
  219. query: {
  220. ...currentQuery,
  221. sort: 'count',
  222. },
  223. }}
  224. >
  225. {t('Count')} {sort === 'count' && sortArrow}
  226. </StyledSortLink>
  227. );
  228. return (
  229. <Layout.Body>
  230. <Layout.Main fullWidth>
  231. <TitleWrapper>
  232. <Title>{t('Tag Details')}</Title>
  233. <ButtonBar gap={1}>
  234. <Button
  235. size="sm"
  236. priority="default"
  237. href={`/${orgId}/${group.project.slug}/issues/${group.id}/tags/${tagKey}/export/`}
  238. >
  239. {t('Export Page to CSV')}
  240. </Button>
  241. <DataExport
  242. payload={{
  243. queryType: ExportQueryType.ISSUES_BY_TAG,
  244. queryInfo: {
  245. project: group.project.id,
  246. group: group.id,
  247. key: tagKey,
  248. },
  249. }}
  250. />
  251. </ButtonBar>
  252. </TitleWrapper>
  253. <StyledPanelTable
  254. isLoading={loading}
  255. isEmpty={tagValueList?.length === 0}
  256. headers={[
  257. title,
  258. <PercentColumnHeader key="percent">{t('Percent')}</PercentColumnHeader>,
  259. countColumnHeader,
  260. lastSeenColumnHeader,
  261. '',
  262. ]}
  263. emptyMessage={t('Sorry, the tags for this issue could not be found.')}
  264. emptyAction={
  265. environments?.length
  266. ? t('No tags were found for the currently selected environments')
  267. : null
  268. }
  269. >
  270. {tagValueList && tag && this.renderResults()}
  271. </StyledPanelTable>
  272. <StyledPagination pageLinks={tagValueListPageLinks} />
  273. </Layout.Main>
  274. </Layout.Body>
  275. );
  276. }
  277. }
  278. export default withSentryRouter(withOrganization(GroupTagValues));
  279. const TitleWrapper = styled('div')`
  280. display: flex;
  281. flex-direction: row;
  282. flex-wrap: wrap;
  283. align-items: center;
  284. justify-content: space-between;
  285. margin-bottom: ${space(2)};
  286. `;
  287. const Title = styled('h3')`
  288. margin: 0;
  289. `;
  290. const StyledPanelTable = styled(PanelTable)`
  291. white-space: nowrap;
  292. font-size: ${p => p.theme.fontSizeMedium};
  293. overflow: auto;
  294. @media (min-width: ${p => p.theme.breakpoints.small}) {
  295. overflow: initial;
  296. }
  297. & > * {
  298. padding: ${space(1)} ${space(2)};
  299. }
  300. `;
  301. const PercentColumnHeader = styled('div')`
  302. text-align: right;
  303. `;
  304. const StyledSortLink = styled(Link)`
  305. text-align: right;
  306. color: inherit;
  307. :hover {
  308. color: inherit;
  309. }
  310. `;
  311. const StyledExternalLink = styled(ExternalLink)`
  312. margin-left: ${space(0.5)};
  313. `;
  314. const Column = styled('div')`
  315. display: flex;
  316. align-items: center;
  317. `;
  318. const NameColumn = styled(Column)`
  319. ${p => p.theme.overflowEllipsis};
  320. display: flex;
  321. min-width: 320px;
  322. `;
  323. const NameWrapper = styled('span')`
  324. ${p => p.theme.overflowEllipsis};
  325. width: auto;
  326. `;
  327. const RightAlignColumn = styled(Column)`
  328. justify-content: flex-end;
  329. `;
  330. const StyledPagination = styled(Pagination)`
  331. margin: 0;
  332. `;