diff --git a/src/sentry/static/sentry/app/views/organizationDiscover/discover.jsx b/src/sentry/static/sentry/app/views/organizationDiscover/discover.jsx
index 2600c793fcc00a..d739be9881dce5 100644
--- a/src/sentry/static/sentry/app/views/organizationDiscover/discover.jsx
+++ b/src/sentry/static/sentry/app/views/organizationDiscover/discover.jsx
@@ -22,7 +22,8 @@ import {
SavedQueryWrapper,
} from './styles';
import {
- getQueryStringFromQuery,
+ areQueriesEqual,
+ getQueryObjectFromQuery,
getQueryFromQueryString,
deleteSavedQuery,
updateSavedQuery,
@@ -67,19 +68,19 @@ export default class OrganizationDiscover extends React.Component {
componentDidMount() {
if (this.props.savedQuery) {
- this.runQuery();
+ this.runQuery({keepVisual: true});
}
}
componentWillReceiveProps(nextProps) {
const {
queryBuilder,
- location: {search},
+ location: {search, query},
savedQuery,
isEditingSavedQuery,
params,
} = nextProps;
- const currentSearch = this.props.location.search;
+ const currentQuery = this.props.location.query;
const {resultManager} = this.state;
if (savedQuery && savedQuery !== this.props.savedQuery) {
@@ -92,7 +93,9 @@ export default class OrganizationDiscover extends React.Component {
return;
}
- if (currentSearch === search) {
+ // Don't compare `visual` since there is no need to re-query if queries are equal
+ // but views are changing
+ if (areQueriesEqual(currentQuery, query)) {
return;
}
@@ -177,8 +180,11 @@ export default class OrganizationDiscover extends React.Component {
this.runQuery();
};
- runQuery = () => {
- const {queryBuilder, organization} = this.props;
+ // runQuery can get called from many different paths - only keep visual in URL params
+ // when the option is passed. We want it to reset in most cases (but not when running query
+ // when initially mounted)
+ runQuery = ({keepVisual} = {}) => {
+ const {queryBuilder, organization, location} = this.props;
const {resultManager} = this.state;
// Track query for analytics
@@ -212,7 +218,10 @@ export default class OrganizationDiscover extends React.Component {
if (shouldRedirect) {
browserHistory.push({
pathname: `/organizations/${organization.slug}/discover/`,
- search: getQueryStringFromQuery(queryBuilder.getInternal()),
+ query: {
+ ...getQueryObjectFromQuery(queryBuilder.getInternal()),
+ visual: keepVisual && location.query.visual,
+ },
});
}
diff --git a/src/sentry/static/sentry/app/views/organizationDiscover/result/index.jsx b/src/sentry/static/sentry/app/views/organizationDiscover/result/index.jsx
index 77aa4d2fb6405b..54733557922a0d 100644
--- a/src/sentry/static/sentry/app/views/organizationDiscover/result/index.jsx
+++ b/src/sentry/static/sentry/app/views/organizationDiscover/result/index.jsx
@@ -1,19 +1,16 @@
-import React from 'react';
-import PropTypes from 'prop-types';
import {throttle} from 'lodash';
+import {withRouter} from 'react-router';
+import PropTypes from 'prop-types';
+import React from 'react';
-import SentryTypes from 'app/sentryTypes';
import {t} from 'app/locale';
-import getDynamicText from 'app/utils/getDynamicText';
import BarChart from 'app/components/charts/barChart';
-import LineChart from 'app/components/charts/lineChart';
import InlineSvg from 'app/components/inlineSvg';
+import LineChart from 'app/components/charts/lineChart';
import PageHeading from 'app/components/pageHeading';
+import SentryTypes from 'app/sentryTypes';
+import getDynamicText from 'app/utils/getDynamicText';
-import {getChartData, getChartDataByDay, getRowsPageRange, downloadAsCsv} from './utils';
-import Table from './table';
-import Pagination from './pagination';
-import VisualizationsToggle from './visualizationsToggle';
import {
HeadingContainer,
ResultSummary,
@@ -25,19 +22,26 @@ import {
ResultSummaryAndButtons,
} from '../styles';
import {NUMBER_OF_SERIES_BY_DAY} from '../data';
+import {getChartData, getChartDataByDay, getRowsPageRange, downloadAsCsv} from './utils';
+import Pagination from './pagination';
+import Table from './table';
+import VisualizationsToggle from './visualizationsToggle';
+
+const DEFAULT_VIEW = 'table';
-export default class Result extends React.Component {
+class Result extends React.Component {
static propTypes = {
+ location: PropTypes.object,
data: PropTypes.object.isRequired,
savedQuery: SentryTypes.DiscoverSavedQuery, // Provided if it's a saved search
onFetchPage: PropTypes.func.isRequired,
onToggleEdit: PropTypes.func,
};
- constructor() {
- super();
+ constructor(props) {
+ super(props);
this.state = {
- view: 'table',
+ view: props.location.query.visual || DEFAULT_VIEW,
height: null,
width: null,
};
@@ -50,6 +54,17 @@ export default class Result extends React.Component {
componentWillReceiveProps(nextProps) {
const {baseQuery, byDayQuery} = nextProps.data;
+ if (nextProps.location.query.visual) {
+ this.setState({
+ view: nextProps.location.query.visual,
+ });
+ } else if (this.props.location.query.visual) {
+ // Going from selected -> none (e.g. clicking back), should show default
+ this.setState({
+ view: DEFAULT_VIEW,
+ });
+ }
+
if (!byDayQuery.data && ['line-by-day', 'bar-by-day'].includes(this.state.view)) {
this.setState({
view: 'table',
@@ -88,12 +103,6 @@ export default class Result extends React.Component {
throttledUpdateDimensions = throttle(this.updateDimensions, 200, {trailing: true});
- handleToggleVisualizations = opt => {
- this.setState({
- view: opt,
- });
- };
-
renderToggle() {
const {baseQuery, byDayQuery} = this.props.data;
@@ -116,7 +125,6 @@ export default class Result extends React.Component {
@@ -269,3 +277,6 @@ export default class Result extends React.Component {
);
}
}
+
+export default withRouter(Result);
+export {Result};
diff --git a/src/sentry/static/sentry/app/views/organizationDiscover/result/visualizationsToggle.jsx b/src/sentry/static/sentry/app/views/organizationDiscover/result/visualizationsToggle.jsx
index b6635f20d61477..777c53a41f0a9c 100644
--- a/src/sentry/static/sentry/app/views/organizationDiscover/result/visualizationsToggle.jsx
+++ b/src/sentry/static/sentry/app/views/organizationDiscover/result/visualizationsToggle.jsx
@@ -1,9 +1,11 @@
+import {Link, withRouter} from 'react-router';
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
+
+import {t} from 'app/locale';
import DropdownLink from 'app/components/dropdownLink';
import MenuItem from 'app/components/menuItem';
-import {t} from 'app/locale';
import {
ResultViewActions,
@@ -20,18 +22,31 @@ class VisualizationsToggle extends React.Component {
name: PropTypes.string,
})
).isRequired,
- handleChange: PropTypes.func.isRequired,
handleCsvDownload: PropTypes.func.isRequired,
visualization: PropTypes.string.isRequired,
};
+ getUrl = id => {
+ const {location} = this.props;
+
+ return {
+ ...location,
+ query: {
+ ...location.query,
+ visual: id,
+ },
+ };
+ };
+
getMenuItem = opt => {
+ const {location, visualization} = this.props;
return (
@@ -42,7 +57,7 @@ class VisualizationsToggle extends React.Component {
const active = opt.id === this.props.visualization;
return (
- this.props.handleChange(opt.id)}>{opt.name}
+ {opt.name}
);
};
@@ -81,4 +96,4 @@ class VisualizationsToggle extends React.Component {
}
}
-export default VisualizationsToggle;
+export default withRouter(VisualizationsToggle);
diff --git a/src/sentry/static/sentry/app/views/organizationDiscover/utils.jsx b/src/sentry/static/sentry/app/views/organizationDiscover/utils.jsx
index a9099e2ec3be44..4a26f43140ab76 100644
--- a/src/sentry/static/sentry/app/views/organizationDiscover/utils.jsx
+++ b/src/sentry/static/sentry/app/views/organizationDiscover/utils.jsx
@@ -1,21 +1,25 @@
+import {isEqual, pick} from 'lodash';
import moment from 'moment';
+
import {Client} from 'app/api';
-import {isValidAggregation} from './aggregations/utils';
+import {defined} from 'app/utils';
+
import {NON_SNUBA_FIELDS} from './data';
+import {isValidAggregation} from './aggregations/utils';
-export function getQueryFromQueryString(queryString) {
- const validQueryKeys = new Set([
- 'projects',
- 'fields',
- 'conditions',
- 'aggregations',
- 'range',
- 'start',
- 'end',
- 'orderby',
- 'limit',
- ]);
+const validQueryKeys = new Set([
+ 'projects',
+ 'fields',
+ 'conditions',
+ 'aggregations',
+ 'range',
+ 'start',
+ 'end',
+ 'orderby',
+ 'limit',
+]);
+export function getQueryFromQueryString(queryString) {
const result = {};
let parsedQuery = queryString;
parsedQuery = parsedQuery.replace(/^\?|\/$/g, '').split('&');
@@ -31,12 +35,19 @@ export function getQueryFromQueryString(queryString) {
return result;
}
-export function getQueryStringFromQuery(query) {
- const queryProperties = Object.entries(query).map(([key, value]) => {
- return key + '=' + encodeURIComponent(JSON.stringify(value));
- });
+export function getQueryObjectFromQuery(query) {
+ // Filter out undefined/null values
+ const queryProperties = Object.entries(query)
+ .filter(([key, value]) => defined(value))
+ .map(([key, value]) => {
+ return [key, JSON.stringify(value)];
+ })
+ .reduce((acc, [key, val]) => {
+ acc[key] = val;
+ return acc;
+ }, {});
- return `?${queryProperties.join('&')}`;
+ return queryProperties;
}
export function getOrderbyFields(queryBuilder) {
@@ -99,6 +110,11 @@ export function getView(params, requestedView) {
}
}
+export function areQueriesEqual(query, other) {
+ const validKeys = Array.from(validQueryKeys);
+ return isEqual(pick(query, validKeys), pick(other, validKeys));
+}
+
/**
* Takes a saved query and strips associated query metadata in order to match
* our internal representation of queries.
diff --git a/tests/js/spec/views/organizationDiscover/discover.spec.jsx b/tests/js/spec/views/organizationDiscover/discover.spec.jsx
index 451adbf4be3752..e463035b0a3548 100644
--- a/tests/js/spec/views/organizationDiscover/discover.spec.jsx
+++ b/tests/js/spec/views/organizationDiscover/discover.spec.jsx
@@ -1,5 +1,6 @@
import {browserHistory} from 'react-router';
import React from 'react';
+import qs from 'query-string';
import {mount} from 'enzyme';
import ConfigStore from 'app/stores/configStore';
@@ -69,7 +70,7 @@ describe('Discover', function() {
});
});
- describe('componentWillRecieveProps()', function() {
+ describe('componentWillReceiveProps()', function() {
it('handles navigating to saved query', function() {
const wrapper = mount(
+ Promise.resolve({
+ timing: {},
+ data: [{foo: 'bar', 'project.id': project.id}],
+ meta: [{name: 'foo'}],
+ })
+ );
+ const wrapper = mount(
+ ,
+ TestStubs.routerContext([{organization}])
+ );
+ wrapper.setProps({
+ location: {
+ search:
+ 'projects=%5B%5D&fields=%5B%22id%22%2C%22issue.id%22%2C%22project.name%22%2C%22platform%22%2C%22timestamp%22%5D&conditions=%5B%5D&aggregations=%5B%5D&range=%2214d%22&orderby=%22-timestamp%22&limit=1000&start=null&end=null',
+ },
+ });
+ await tick();
+ wrapper.update();
+ queryBuilder.fetch.mockClear();
+ wrapper.setProps({
+ location: {
+ search:
+ 'projects=%5B%5D&fields=%5B%22id%22%2C%22issue.id%22%2C%22project.name%22%2C%22platform%22%2C%22timestamp%22%5D&conditions=%5B%5D&aggregations=%5B%5D&range=%2214d%22&orderby=%22-timestamp%22&limit=1000&start=null&end=null&visual=bar',
+ },
+ });
+ await tick();
+ wrapper.update();
+ expect(queryBuilder.fetch).not.toHaveBeenCalled();
+ });
});
describe('Pagination', function() {
@@ -306,10 +349,11 @@ describe('Discover', function() {
let wrapper;
beforeEach(function() {
const mockResponse = {timing: {}, data: [], meta: []};
- browserHistory.push.mockImplementation(function({search}) {
+ browserHistory.push.mockImplementation(function({search, query}) {
wrapper.setProps({
location: {
- search: search || '',
+ search: search || qs.stringify(query),
+ query,
},
});
});
@@ -321,7 +365,7 @@ describe('Discover', function() {
,
+ ,
{
context: {organization},
disableLifecycleMethods: false,
@@ -140,11 +147,23 @@ describe('Result', function() {
});
describe('Toggles Visualizations', function() {
+ organization = TestStubs.Organization();
+ const {routerContext, router} = initializeOrg({
+ organization,
+ router: {
+ location: {
+ pathname: `/organizations/${organization.slug}/discover/`,
+ query: {},
+ },
+ },
+ });
+
beforeEach(function() {
wrapper = mount(
,
- TestStubs.routerContext([{organization}])
+ routerContext
);
+ mockRouterPush(wrapper, router);
});
it('displays options', function() {
@@ -158,9 +177,14 @@ describe('Result', function() {
wrapper
.find('ResultViewButtons')
- .find('a')
+ .find('Link a')
.at(1)
- .simulate('click');
+ .simulate('click', {button: 0});
+
+ expect(router.push).toHaveBeenCalledWith({
+ pathname: '/organizations/org-slug/discover/',
+ query: {visual: 'line'},
+ });
wrapper.update();
expect(wrapper.find('ResultTable')).toHaveLength(0);
@@ -175,7 +199,7 @@ describe('Result', function() {
.find('ul.dropdown-menu')
.find('a')
.at(1)
- .simulate('click');
+ .simulate('click', {button: 0});
expect(wrapper.find('ResultTable')).toHaveLength(0);
expect(wrapper.find('LineChart')).toHaveLength(1);
diff --git a/tests/js/spec/views/organizationDiscover/utils.spec.jsx b/tests/js/spec/views/organizationDiscover/utils.spec.jsx
index 1595e59302690c..174db322a1bfec 100644
--- a/tests/js/spec/views/organizationDiscover/utils.spec.jsx
+++ b/tests/js/spec/views/organizationDiscover/utils.spec.jsx
@@ -1,6 +1,6 @@
import {
getQueryFromQueryString,
- getQueryStringFromQuery,
+ getQueryObjectFromQuery,
getOrderbyFields,
parseSavedQuery,
generateQueryName,
@@ -40,9 +40,18 @@ describe('getQueryFromQueryString()', function() {
});
});
-describe('getQueryStringFromQuery()', function() {
+describe('getQueryObjectFromQuery()', function() {
it('parses query from query string', function() {
- expect(getQueryStringFromQuery(query)).toEqual(queryString);
+ expect(getQueryObjectFromQuery(query)).toEqual({
+ aggregations: '[["count()",null,"count"],["uniq","os_build","uniq_os_build"]]',
+ conditions: '[]',
+ end: '"2018-07-10T01:18:04"',
+ fields: '["id","timestamp"]',
+ limit: '1000',
+ orderby: '"-timestamp"',
+ projects: '[8]',
+ start: '"2018-06-26T01:18:04"',
+ });
});
});