tags.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import {Location, LocationDescriptor} from 'history';
  5. import {fetchTagFacets, Tag, TagSegment} from 'sentry/actionCreators/events';
  6. import {Client} from 'sentry/api';
  7. import {Button} from 'sentry/components/button';
  8. import ErrorPanel from 'sentry/components/charts/errorPanel';
  9. import {SectionHeading} from 'sentry/components/charts/styles';
  10. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  11. import {TagFacetsList} from 'sentry/components/group/tagFacets';
  12. import TagFacetsDistributionMeter from 'sentry/components/group/tagFacets/tagFacetsDistributionMeter';
  13. import Placeholder from 'sentry/components/placeholder';
  14. import {IconWarning} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {Organization} from 'sentry/types';
  18. import {trackAnalytics} from 'sentry/utils/analytics';
  19. import EventView, {isAPIPayloadSimilar} from 'sentry/utils/discover/eventView';
  20. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  21. import withApi from 'sentry/utils/withApi';
  22. type Props = {
  23. api: Client;
  24. eventView: EventView;
  25. generateUrl: (key: string, value: string) => LocationDescriptor;
  26. location: Location;
  27. organization: Organization;
  28. totalValues: null | number;
  29. confirmedQuery?: boolean;
  30. onTagValueClick?: (title: string, value: TagSegment) => void;
  31. };
  32. type State = {
  33. error: string;
  34. hasLoaded: boolean;
  35. hasMore: boolean;
  36. loading: boolean;
  37. tags: Tag[];
  38. totalValues: null | number;
  39. nextCursor?: string;
  40. tagLinks?: string;
  41. };
  42. class Tags extends Component<Props, State> {
  43. state: State = {
  44. loading: true,
  45. tags: [],
  46. totalValues: null,
  47. error: '',
  48. hasMore: false,
  49. hasLoaded: false,
  50. };
  51. componentDidMount() {
  52. this.fetchData(true);
  53. }
  54. componentDidUpdate(prevProps: Props) {
  55. if (
  56. this.shouldRefetchData(prevProps) ||
  57. prevProps.confirmedQuery !== this.props.confirmedQuery
  58. ) {
  59. this.fetchData();
  60. }
  61. }
  62. shouldRefetchData = (prevProps: Props): boolean => {
  63. const thisAPIPayload = this.props.eventView.getFacetsAPIPayload(this.props.location);
  64. const otherAPIPayload = prevProps.eventView.getFacetsAPIPayload(prevProps.location);
  65. return !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload);
  66. };
  67. fetchData = async (
  68. forceFetchData = false,
  69. nextCursor?: string,
  70. appendTags?: boolean
  71. ) => {
  72. const {api, organization, eventView, location, confirmedQuery} = this.props;
  73. this.setState({loading: true, error: ''});
  74. if (!appendTags) {
  75. this.setState({hasLoaded: false, tags: []});
  76. }
  77. // Fetch should be forced after mounting as confirmedQuery isn't guaranteed
  78. // since this component can mount/unmount via show/hide tags separate from
  79. // data being loaded for the rest of the page.
  80. if (!forceFetchData && confirmedQuery === false) {
  81. return;
  82. }
  83. try {
  84. const [data, , resp] = await fetchTagFacets(api, organization.slug, {
  85. ...eventView.getFacetsAPIPayload(location),
  86. cursor: nextCursor,
  87. });
  88. const pageLinks = resp?.getResponseHeader('Link') ?? undefined;
  89. let hasMore = false;
  90. let cursor: string | undefined;
  91. if (pageLinks) {
  92. const paginationObject = parseLinkHeader(pageLinks);
  93. hasMore = paginationObject?.next?.results ?? false;
  94. cursor = paginationObject.next?.cursor;
  95. }
  96. let tags = data;
  97. if (!organization.features.includes('device-classification')) {
  98. tags = tags.filter(tag => tag.key !== 'device.class');
  99. }
  100. if (appendTags) {
  101. tags = [...this.state.tags, ...tags];
  102. }
  103. this.setState({loading: false, hasLoaded: true, tags, hasMore, nextCursor: cursor});
  104. } catch (err) {
  105. if (
  106. err.status !== 400 &&
  107. err.responseJSON?.detail !==
  108. 'Invalid date range. Please try a more recent date range.'
  109. ) {
  110. Sentry.captureException(err);
  111. }
  112. this.setState({loading: false, error: err});
  113. }
  114. };
  115. handleTagClick = (tag: string) => {
  116. const {organization} = this.props;
  117. // metrics
  118. trackAnalytics('discover_v2.facet_map.clicked', {organization, tag});
  119. };
  120. renderTag(tag: Tag, index: number) {
  121. const {generateUrl, onTagValueClick, totalValues} = this.props;
  122. const segments: TagSegment[] = tag.topValues.map(segment => {
  123. segment.url = generateUrl(tag.key, segment.value);
  124. return segment;
  125. });
  126. // Ensure we don't show >100% if there's a slight mismatch between the facets
  127. // endpoint and the totals endpoint
  128. const maxTotalValues =
  129. segments.length > 0
  130. ? Math.max(Number(totalValues), segments[0].count)
  131. : totalValues;
  132. return (
  133. <li key={tag.key} aria-label={tag.key}>
  134. <TagFacetsDistributionMeter
  135. title={tag.key}
  136. segments={segments}
  137. totalValues={Number(maxTotalValues)}
  138. expandByDefault={index === 0}
  139. onTagValueClick={onTagValueClick}
  140. />
  141. </li>
  142. );
  143. }
  144. renderPlaceholders() {
  145. return (
  146. <Fragment>
  147. <StyledPlaceholderTitle key="title-1" />
  148. <StyledPlaceholder key="bar-1" />
  149. <StyledPlaceholderTitle key="title-2" />
  150. <StyledPlaceholder key="bar-2" />
  151. <StyledPlaceholderTitle key="title-3" />
  152. <StyledPlaceholder key="bar-3" />
  153. </Fragment>
  154. );
  155. }
  156. renderBody = () => {
  157. const {loading, hasLoaded, error, tags, hasMore, nextCursor} = this.state;
  158. if (loading && !hasLoaded) {
  159. return this.renderPlaceholders();
  160. }
  161. if (error) {
  162. return (
  163. <ErrorPanel height="132px">
  164. <IconWarning color="gray300" size="lg" />
  165. </ErrorPanel>
  166. );
  167. }
  168. if (tags.length > 0) {
  169. return (
  170. <Fragment>
  171. {/* sentry-discover-tags-chromext depends on a stable id */}
  172. <StyledTagFacetList id="tag-facet-list">
  173. {tags.map((tag, index) => this.renderTag(tag, index))}
  174. </StyledTagFacetList>
  175. {hasMore &&
  176. (loading ? (
  177. this.renderPlaceholders()
  178. ) : (
  179. <ButtonWrapper>
  180. <Button
  181. size="xs"
  182. priority="primary"
  183. disabled={loading}
  184. aria-label={t('Show More')}
  185. onClick={() => {
  186. this.fetchData(true, nextCursor, true);
  187. }}
  188. >
  189. {t('Show More')}
  190. </Button>
  191. </ButtonWrapper>
  192. ))}
  193. </Fragment>
  194. );
  195. }
  196. return <StyledEmptyStateWarning small>{t('No tags found')}</StyledEmptyStateWarning>;
  197. };
  198. render() {
  199. return (
  200. <Fragment>
  201. <SectionHeading>{t('Tag Summary')}</SectionHeading>
  202. {this.renderBody()}
  203. </Fragment>
  204. );
  205. }
  206. }
  207. const StyledEmptyStateWarning = styled(EmptyStateWarning)`
  208. height: 132px;
  209. padding: 54px 15%;
  210. `;
  211. const StyledPlaceholder = styled(Placeholder)`
  212. border-radius: ${p => p.theme.borderRadius};
  213. height: 16px;
  214. margin-bottom: ${space(1.5)};
  215. `;
  216. const StyledPlaceholderTitle = styled(Placeholder)`
  217. width: 100px;
  218. height: 12px;
  219. margin-bottom: ${space(0.5)};
  220. `;
  221. const StyledTagFacetList = styled(TagFacetsList)`
  222. margin-bottom: 0;
  223. width: 100%;
  224. `;
  225. const ButtonWrapper = styled('div')`
  226. display: flex;
  227. flex-direction: column;
  228. align-items: center;
  229. `;
  230. export {Tags};
  231. export default withApi(Tags);