From 7c83fffca414bd424a9d6cec8391a8255c135f5e Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Tue, 4 Jun 2019 10:55:51 -0700 Subject: [PATCH] feat(events-v2): Implement tag distribution / heatmaps UI (#13478) SEN-672 --- .../components/tagDistributionMeter/index.jsx | 2 +- .../app/views/organizationEventsV2/events.jsx | 5 +- .../app/views/organizationEventsV2/tags.jsx | 76 ++++++++++++++++++- .../views/organizationEventsV2/tagsTable.jsx | 6 +- .../app/views/organizationEventsV2/utils.jsx | 31 +++++++- .../views/organizationEventsV2/tags.spec.jsx | 56 ++++++++++++++ .../views/organizationEventsV2/utils.spec.jsx | 8 +- 7 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 tests/js/spec/views/organizationEventsV2/tags.spec.jsx diff --git a/src/sentry/static/sentry/app/components/tagDistributionMeter/index.jsx b/src/sentry/static/sentry/app/components/tagDistributionMeter/index.jsx index e7385c4b53a150..f6bc48b8d5e450 100644 --- a/src/sentry/static/sentry/app/components/tagDistributionMeter/index.jsx +++ b/src/sentry/static/sentry/app/components/tagDistributionMeter/index.jsx @@ -19,7 +19,7 @@ export default class TagDistributionMeter extends React.Component { count: PropTypes.number.isRequired, name: PropTypes.string.isRequired, value: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, + url: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, }) ).isRequired, renderEmpty: PropTypes.func, diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/events.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/events.jsx index e3201d3f7e68c0..325f420a9230e5 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/events.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/events.jsx @@ -25,7 +25,8 @@ const CHART_AXIS_OPTIONS = [ class Events extends AsyncComponent { static propTypes = { - router: PropTypes.object, + router: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, organization: SentryTypes.Organization.isRequired, view: SentryTypes.EventView.isRequired, }; @@ -115,7 +116,7 @@ class Events extends AsyncComponent { /> - + ); diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/tags.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/tags.jsx index 1a2c7678b457a2..1f295a8c39a8e4 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/tags.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/tags.jsx @@ -1,18 +1,90 @@ import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'react-emotion'; +import {isEqual} from 'lodash'; import SentryTypes from 'app/sentryTypes'; import TagDistributionMeter from 'app/components/tagDistributionMeter'; +import withApi from 'app/utils/withApi'; +import withGlobalSelection from 'app/utils/withGlobalSelection'; +import {fetchTags, getEventTagSearchUrl} from './utils'; -export default class Tags extends React.Component { +class Tags extends React.Component { static propTypes = { + api: PropTypes.object.isRequired, + organization: SentryTypes.Organization.isRequired, view: SentryTypes.EventView.isRequired, + selection: SentryTypes.GlobalSelection.isRequired, + location: PropTypes.object.isRequired, + }; + + state = { + isLoading: true, + tags: {}, + }; + + componentDidMount() { + this.fetchData(); + } + + componentDidUpdate(prevProps) { + if ( + this.props.view.id !== prevProps.view.id || + !isEqual(this.props.selection, prevProps.selection) + ) { + this.fetchData(); + } + } + + fetchData = async () => { + const {api, organization, view} = this.props; + + try { + const tags = await fetchTags(api, organization.slug, view.tags); + this.setState({tags, isLoading: false}); + } catch (err) { + this.setState({tags: {}, isLoading: false}); + } }; renderTag(tag) { - return ; + const {location} = this.props; + const {isLoading, tags} = this.state; + let segments = []; + let totalValues = 0; + if (!isLoading && tags[tag]) { + totalValues = tags[tag].totalValues; + segments = tags[tag].topValues; + } + + segments.forEach(segment => { + segment.url = getEventTagSearchUrl(tag, segment.value, location); + }); + + return ( + } + /> + ); } render() { return
{this.props.view.tags.map(tag => this.renderTag(tag))}
; } } + +const Placeholder = styled('div')` + height: 16px; + width: 100%; + display: inline-block; + border-radius: ${p => p.theme.borderRadius}; + background-color: #dad9ed; +`; + +export {Tags}; +export default withApi(withGlobalSelection(Tags)); diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/tagsTable.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/tagsTable.jsx index 50d8f9ab9978f9..3b02fcd557eaa9 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/tagsTable.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/tagsTable.jsx @@ -6,7 +6,7 @@ import {withRouter} from 'react-router'; import Link from 'app/components/links/link'; import {t} from 'app/locale'; import space from 'app/styles/space'; -import {eventTagSearchUrl} from './utils'; +import {getEventTagSearchUrl} from './utils'; const TagsTable = props => { return ( @@ -18,7 +18,9 @@ const TagsTable = props => { {tag.key} - {tag.value} + + {tag.value} + ))} diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/utils.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/utils.jsx index 00295c05bf5e54..a38a8b992e55ff 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/utils.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/utils.jsx @@ -58,17 +58,18 @@ export function getQuery(view, location) { * Return a location object for the current pathname * with a query string reflected the provided tag. * - * @param {Object} tag containing key/value properties + * @param {String} tagKey + * @param {String} tagValue * @param {Object} browser location object. * @return {Object} router target */ -export function eventTagSearchUrl(tag, location) { +export function getEventTagSearchUrl(tagKey, tagValue, location) { const query = {...location.query}; // Add tag key/value to search if (query.query) { - query.query += ` ${tag.key}:"${tag.value}"`; + query.query += ` ${tagKey}:"${tagValue}"`; } else { - query.query = `${tag.key}:"${tag.value}"`; + query.query = `${tagKey}:"${tagValue}"`; } // Remove the event slug so the user sees new search results. delete query.eventSlug; @@ -78,3 +79,25 @@ export function eventTagSearchUrl(tag, location) { query, }; } + +/** + * Fetches tag distributions for heatmaps for an array of tag keys + * + * @param {Object} api + * @param {String} orgSlug + * @param {Array} tagList + * @returns {Promise} + */ +export function fetchTags(api, orgSlug, tagList) { + return api + .requestPromise(`/organizations/${orgSlug}/events-heatmap/`, { + query: {keys: tagList}, + }) + .then(resp => { + const tags = {}; + resp.forEach(tag => { + tags[tag.key] = tag; + }); + return tags; + }); +} diff --git a/tests/js/spec/views/organizationEventsV2/tags.spec.jsx b/tests/js/spec/views/organizationEventsV2/tags.spec.jsx new file mode 100644 index 00000000000000..f8d67666d1657e --- /dev/null +++ b/tests/js/spec/views/organizationEventsV2/tags.spec.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {mount} from 'enzyme'; + +import {Client} from 'app/api'; +import {Tags} from 'app/views/organizationEventsV2/tags'; + +describe('Tags', function() { + const org = TestStubs.Organization(); + beforeEach(function() { + Client.addMockResponse({ + url: `/organizations/${org.slug}/events-heatmap/`, + body: [ + { + key: 'release', + name: 'Release', + totalValues: 2, + topValues: [{count: 2, value: 'abcd123', name: 'abcd123'}], + }, + { + key: 'environment', + name: 'Environment', + totalValues: 1, + topValues: [{count: 1, value: 'production', name: 'production'}], + }, + ], + }); + }); + + afterEach(function() { + Client.clearMockResponses(); + }); + + it('renders', async function() { + const api = new Client(); + const view = { + id: 'test', + name: 'Test', + data: {}, + tags: ['release', 'environment'], + }; + const wrapper = mount( + + ); + + expect(wrapper.find('Placeholder')).toHaveLength(2); + await tick(); + wrapper.update(); + expect(wrapper.find('Placeholder')).toHaveLength(0); + }); +}); diff --git a/tests/js/spec/views/organizationEventsV2/utils.spec.jsx b/tests/js/spec/views/organizationEventsV2/utils.spec.jsx index 77799297da8261..2366d86f3c0773 100644 --- a/tests/js/spec/views/organizationEventsV2/utils.spec.jsx +++ b/tests/js/spec/views/organizationEventsV2/utils.spec.jsx @@ -1,7 +1,7 @@ import { getCurrentView, getQuery, - eventTagSearchUrl, + getEventTagSearchUrl, } from 'app/views/organizationEventsV2/utils'; import {ALL_VIEWS} from 'app/views/organizationEventsV2/data'; @@ -52,7 +52,7 @@ describe('eventTagSearchUrl()', function() { }); it('adds a query', function() { - expect(eventTagSearchUrl({key: 'browser', value: 'firefox'}, location)).toEqual({ + expect(getEventTagSearchUrl('browser', 'firefox', location)).toEqual({ pathname: location.pathname, query: {query: 'browser:"firefox"'}, }); @@ -60,7 +60,7 @@ describe('eventTagSearchUrl()', function() { it('removes eventSlug', function() { location.query.eventSlug = 'project-slug:deadbeef'; - expect(eventTagSearchUrl({key: 'browser', value: 'firefox'}, location)).toEqual({ + expect(getEventTagSearchUrl('browser', 'firefox', location)).toEqual({ pathname: location.pathname, query: {query: 'browser:"firefox"'}, }); @@ -68,7 +68,7 @@ describe('eventTagSearchUrl()', function() { it('appends to an existing query', function() { location.query.query = 'failure'; - expect(eventTagSearchUrl({key: 'browser', value: 'firefox'}, location)).toEqual({ + expect(getEventTagSearchUrl('browser', 'firefox', location)).toEqual({ pathname: location.pathname, query: {query: 'failure browser:"firefox"'}, });