diff --git a/src/sentry/static/sentry/app/components/charts/baseChart.jsx b/src/sentry/static/sentry/app/components/charts/baseChart.jsx
index d1d88f0cac96ed..188a50014eed99 100644
--- a/src/sentry/static/sentry/app/components/charts/baseChart.jsx
+++ b/src/sentry/static/sentry/app/components/charts/baseChart.jsx
@@ -120,8 +120,8 @@ class BaseChart extends React.Component {
// x-axis and tooltips.
isGroupedByDate: PropTypes.bool,
- // How is data grouped (affects formatting of axis labels and tooltips)
- interval: PropTypes.oneOf(['hour', 'day']),
+ // Should we render hours on xaxis instead of day?
+ shouldXAxisRenderTimeOnly: PropTypes.bool,
// Formats dates as UTC?
utc: PropTypes.bool,
@@ -140,7 +140,7 @@ class BaseChart extends React.Component {
xAxis: {},
yAxis: {},
isGroupedByDate: false,
- interval: 'day',
+ shouldXAxisRenderTimeOnly: false,
};
getEventsMap = () => {
@@ -197,7 +197,7 @@ class BaseChart extends React.Component {
toolBox,
isGroupedByDate,
- interval,
+ shouldXAxisRenderTimeOnly,
previousPeriod,
utc,
yAxes,
@@ -243,17 +243,14 @@ class BaseChart extends React.Component {
useUTC: utc,
color: colors || this.getColorPalette(),
grid: Grid(grid),
- tooltip:
- tooltip !== null
- ? Tooltip({interval, isGroupedByDate, utc, ...tooltip})
- : null,
+ tooltip: tooltip !== null ? Tooltip({isGroupedByDate, utc, ...tooltip}) : null,
legend: legend ? Legend({...legend}) : null,
yAxis: yAxisOrCustom,
xAxis:
xAxis !== null
? XAxis({
...xAxis,
- interval,
+ shouldRenderTimeOnly: shouldXAxisRenderTimeOnly,
isGroupedByDate,
utc,
})
diff --git a/src/sentry/static/sentry/app/components/charts/chartZoom.jsx b/src/sentry/static/sentry/app/components/charts/chartZoom.jsx
index a331f311a3658c..c62a6cc0c7c3e6 100644
--- a/src/sentry/static/sentry/app/components/charts/chartZoom.jsx
+++ b/src/sentry/static/sentry/app/components/charts/chartZoom.jsx
@@ -1,11 +1,10 @@
-import {withRouter} from 'react-router';
import PropTypes from 'prop-types';
import React from 'react';
import moment from 'moment';
import {callIfFunction} from 'app/utils/callIfFunction';
import {getFormattedDate} from 'app/utils/dates';
-import {getInterval, useShortInterval} from 'app/components/charts/utils';
+import {useShortInterval} from 'app/components/charts/utils';
import {updateParams} from 'app/actionCreators/globalSelection';
import DataZoom from 'app/components/charts/components/dataZoom';
import SentryTypes from 'app/sentryTypes';
@@ -213,7 +212,6 @@ class ChartZoom extends React.Component {
}
const hasShortInterval = useShortInterval(this.props);
- const interval = getInterval(this.props);
const xAxisOptions = {
axisLabel: {
formatter: (value, index) => {
@@ -239,7 +237,6 @@ class ChartZoom extends React.Component {
isGroupedByDate: true,
onChartReady: this.handleChartReady,
utc,
- interval,
dataZoom: DataZoom(),
tooltip,
toolBox: ToolBox(
@@ -269,4 +266,4 @@ class ChartZoom extends React.Component {
}
}
-export default withRouter(ChartZoom);
+export default ChartZoom;
diff --git a/src/sentry/static/sentry/app/components/charts/components/xAxis.jsx b/src/sentry/static/sentry/app/components/charts/components/xAxis.jsx
index 5918cf291f4970..af9fbbae5ac4ed 100644
--- a/src/sentry/static/sentry/app/components/charts/components/xAxis.jsx
+++ b/src/sentry/static/sentry/app/components/charts/components/xAxis.jsx
@@ -2,10 +2,12 @@ import {getFormattedDate} from 'app/utils/dates';
import theme from 'app/utils/theme';
import {truncationFormatter} from '../utils';
-export default function XAxis({isGroupedByDate, interval, utc, ...props} = {}) {
+export default function XAxis(
+ {isGroupedByDate, shouldRenderTimeOnly, utc, ...props} = {}
+) {
const axisLabelFormatter = value => {
if (isGroupedByDate) {
- const format = interval === 'hour' ? 'LT' : 'MMM Do';
+ const format = shouldRenderTimeOnly === 'hour' ? 'LT' : 'MMM Do';
return getFormattedDate(value, format, {local: !utc});
} else if (props.truncate) {
return truncationFormatter(value, props.truncate);
diff --git a/src/sentry/static/sentry/app/components/charts/utils.jsx b/src/sentry/static/sentry/app/components/charts/utils.jsx
index e17d390e64210c..0d22387893cdc6 100644
--- a/src/sentry/static/sentry/app/components/charts/utils.jsx
+++ b/src/sentry/static/sentry/app/components/charts/utils.jsx
@@ -1,7 +1,13 @@
import moment from 'moment';
+import {parsePeriodToHours} from 'app/utils';
+
const DEFAULT_TRUNCATE_LENGTH = 80;
+// In minutes
+const TWENTY_FOUR_HOURS = 1440;
+const THIRTY_MINUTES = 30;
+
export function truncationFormatter(value, truncate) {
if (!truncate) {
return value;
@@ -17,15 +23,41 @@ export function truncationFormatter(value, truncate) {
* Use a shorter interval if the time difference is <= 24 hours.
*/
export function useShortInterval(datetimeObj) {
- const {period, start, end} = datetimeObj;
+ const diffInMinutes = getDiffInMinutes(datetimeObj);
- if (typeof period === 'string') {
- return period.endsWith('h') || period === '1d';
- }
+ return diffInMinutes <= TWENTY_FOUR_HOURS;
+}
+
+export function getInterval(datetimeObj, highFidelity = false) {
+ const diffInMinutes = getDiffInMinutes(datetimeObj);
- return moment(end).diff(start, 'hours') <= 24;
+ if (diffInMinutes > TWENTY_FOUR_HOURS) {
+ // Greater than 24 hours
+ if (highFidelity) {
+ return '30m';
+ } else {
+ return '24h';
+ }
+ } else if (diffInMinutes < THIRTY_MINUTES) {
+ // Less than 30 minutes
+ if (highFidelity) {
+ return '1m';
+ } else {
+ return '5m';
+ }
+ } else {
+ // Between 30 minutes and 24 hours
+ if (highFidelity) {
+ return '5m';
+ } else {
+ return '15m';
+ }
+ }
}
-export function getInterval(datetimeObj) {
- return useShortInterval(datetimeObj) ? '5m' : '30m';
+export function getDiffInMinutes(datetimeObj) {
+ const {period, start, end} = datetimeObj;
+ return typeof period === 'string'
+ ? parsePeriodToHours(period) * 60
+ : moment(end).diff(start, 'minutes');
}
diff --git a/src/sentry/static/sentry/app/sentryTypes.jsx b/src/sentry/static/sentry/app/sentryTypes.jsx
index 41de07730cd9e6..b7ea82c48ca491 100644
--- a/src/sentry/static/sentry/app/sentryTypes.jsx
+++ b/src/sentry/static/sentry/app/sentryTypes.jsx
@@ -93,6 +93,23 @@ export const DiscoverSavedQuery = PropTypes.shape({
...DiscoverQueryShape,
});
+const DiscoverResultsShape = {
+ data: PropTypes.arrayOf(PropTypes.object),
+ meta: PropTypes.arrayOf(
+ PropTypes.shape({
+ type: PropTypes.string,
+ name: PropTypes.string,
+ })
+ ),
+ timing: PropTypes.shape({
+ duration_ms: PropTypes.number,
+ marks_ms: PropTypes.object,
+ timestamp: PropTypes.number,
+ }),
+};
+
+export const DiscoverResults = PropTypes.arrayOf(PropTypes.shape(DiscoverResultsShape));
+
/**
* A Member is someone that was invited to Sentry but may
* not have registered for an account yet
@@ -879,6 +896,7 @@ let SentryTypes = {
Deploy,
DiscoverQuery,
DiscoverSavedQuery,
+ DiscoverResults,
Environment,
Event,
Organization: PropTypes.shape({
diff --git a/src/sentry/static/sentry/app/utils.jsx b/src/sentry/static/sentry/app/utils.jsx
index 4bc85a4538e6c1..d23f0c234e47a4 100644
--- a/src/sentry/static/sentry/app/utils.jsx
+++ b/src/sentry/static/sentry/app/utils.jsx
@@ -249,9 +249,11 @@ export function isWebpackChunkLoadingError(error) {
* and converts it into hours
*/
export function parsePeriodToHours(str) {
- const [, periodNumber, periodLength] = str.match(/([0-9]+)([mhdw])/);
+ const [, periodNumber, periodLength] = str.match(/([0-9]+)([smhdw])/);
switch (periodLength) {
+ case 's':
+ return periodNumber / (60 * 60);
case 'm':
return periodNumber / 60;
case 'h':
@@ -259,7 +261,7 @@ export function parsePeriodToHours(str) {
case 'd':
return periodNumber * 24;
case 'w':
- return periodLength * 24 * 7;
+ return periodNumber * 24 * 7;
default:
return -1;
}
diff --git a/src/sentry/static/sentry/app/views/organizationDashboard/discoverQuery.jsx b/src/sentry/static/sentry/app/views/organizationDashboard/discoverQuery.jsx
index 20708f4e184815..e4f94e7dad5864 100644
--- a/src/sentry/static/sentry/app/views/organizationDashboard/discoverQuery.jsx
+++ b/src/sentry/static/sentry/app/views/organizationDashboard/discoverQuery.jsx
@@ -1,11 +1,12 @@
+import {isEqual, omit} from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
+import {getInterval} from 'app/components/charts/utils';
import {getPeriod} from 'app/utils/getPeriod';
+import {parsePeriodToHours} from 'app/utils';
import SentryTypes from 'app/sentryTypes';
import createQueryBuilder from 'app/views/organizationDiscover/queryBuilder';
-import withGlobalSelection from 'app/utils/withGlobalSelection';
-import withOrganization from 'app/utils/withOrganization';
class DiscoverQuery extends React.Component {
static propTypes = {
@@ -24,6 +25,7 @@ class DiscoverQuery extends React.Component {
this.state = {
results: null,
+ reloading: null,
};
// Query builders based on `queries`
@@ -36,8 +38,24 @@ class DiscoverQuery extends React.Component {
this.fetchData();
}
+ shouldComponentUpdate(nextProps, nextState) {
+ if (this.state !== nextState) {
+ return true;
+ }
+
+ if (
+ this.props.organization === nextProps.organization &&
+ this.props.selection === nextProps.selection
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
componentDidUpdate(prevProps) {
- if (prevProps === this.props) {
+ const keysToIgnore = ['children'];
+ if (isEqual(omit(prevProps, keysToIgnore), omit(this.props, keysToIgnore))) {
return;
}
@@ -67,6 +85,12 @@ class DiscoverQuery extends React.Component {
period = {start, end, range: statsPeriod};
}
+ if (query.rollup) {
+ // getInterval returns a period string depending on current datetime range selected
+ // we then use a helper function to parse into hours and then convert back to seconds
+ query.rollup = parsePeriodToHours(getInterval(datetime)) * 60 * 60;
+ }
+
return {
...query,
...selection,
@@ -88,15 +112,13 @@ class DiscoverQuery extends React.Component {
this.resetQueries();
// Fetch
+ this.setState({reloading: true});
const promises = this.queryBuilders.map(builder => builder.fetchWithoutLimit());
let results = await Promise.all(promises);
- let previousData = null;
- let data = null;
this.setState({
+ reloading: false,
results,
- data,
- previousData,
});
}
@@ -105,11 +127,10 @@ class DiscoverQuery extends React.Component {
return children({
queries: this.queryBuilders.map(builder => builder.getInternal()),
+ reloading: this.state.reloading,
results: this.state.results,
- data: this.state.data,
- previousData: this.state.previousData,
});
}
}
-export default withGlobalSelection(withOrganization(DiscoverQuery));
+export default DiscoverQuery;
diff --git a/src/sentry/static/sentry/app/views/organizationDashboard/exploreWidget.jsx b/src/sentry/static/sentry/app/views/organizationDashboard/exploreWidget.jsx
new file mode 100644
index 00000000000000..ab247f8ea1b3b8
--- /dev/null
+++ b/src/sentry/static/sentry/app/views/organizationDashboard/exploreWidget.jsx
@@ -0,0 +1,69 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {getQueryStringFromQuery} from 'app/views/organizationDiscover/utils';
+import Button from 'app/components/button';
+import InlineSvg from 'app/components/inlineSvg';
+import SentryTypes from 'app/sentryTypes';
+import withOrganization from 'app/utils/withOrganization';
+
+class ExploreWidget extends React.Component {
+ static propTypes = {
+ widget: SentryTypes.Widget,
+ organization: SentryTypes.Organization,
+ selection: SentryTypes.GlobalSelection,
+ router: PropTypes.object,
+ };
+
+ handleExportToDiscover = event => {
+ const {organization, widget, router} = this.props;
+ const [firstQuery] = widget.queries.discover;
+ const {
+ datetime,
+ environments, // eslint-disable-line no-unused-vars
+ ...selection
+ } = this.props.selection;
+
+ event.stopPropagation();
+
+ // Discover does not support importing these
+ const {
+ groupby, // eslint-disable-line no-unused-vars
+ rollup, // eslint-disable-line no-unused-vars
+ orderby,
+ ...query
+ } = firstQuery;
+
+ const orderbyTimeIndex = orderby.indexOf('time');
+ let visual = 'table';
+
+ if (orderbyTimeIndex !== -1) {
+ query.orderby = `${orderbyTimeIndex === 0 ? '' : '-'}${query.aggregations[0][2]}`;
+ visual = 'line-by-day';
+ } else {
+ query.orderby = orderby;
+ }
+
+ router.push(
+ `/organizations/${organization.slug}/discover/${getQueryStringFromQuery({
+ ...query,
+ ...selection,
+ start: datetime.start,
+ end: datetime.end,
+ range: datetime.period,
+ limit: 1000,
+ })}&visual=${visual}`
+ );
+ };
+
+ render() {
+ // TODO(billy): This is temporary
+ // Need design followups
+ return (
+
+ );
+ }
+}
+export default withOrganization(ExploreWidget);
diff --git a/src/sentry/static/sentry/app/views/organizationDashboard/widget.jsx b/src/sentry/static/sentry/app/views/organizationDashboard/widget.jsx
index aeaf033b275f5b..49c0a5ffe9c862 100644
--- a/src/sentry/static/sentry/app/views/organizationDashboard/widget.jsx
+++ b/src/sentry/static/sentry/app/views/organizationDashboard/widget.jsx
@@ -3,19 +3,16 @@ import PropTypes from 'prop-types';
import React from 'react';
import styled from 'react-emotion';
+import {LoadingMask} from 'app/views/organizationEvents/loadingPanel';
import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
import {WIDGET_DISPLAY} from 'app/views/organizationDashboard/constants';
-import {getChartComponent} from 'app/views/organizationDashboard/utils/getChartComponent';
-import {getData} from 'app/views/organizationDashboard/utils/getData';
-import {getQueryStringFromQuery} from 'app/views/organizationDiscover/utils';
-import Button from 'app/components/button';
-import ReleaseSeries from 'app/components/charts/releaseSeries';
-import InlineSvg from 'app/components/inlineSvg';
+import ExploreWidget from 'app/views/organizationDashboard/exploreWidget';
import SentryTypes from 'app/sentryTypes';
import withGlobalSelection from 'app/utils/withGlobalSelection';
import withOrganization from 'app/utils/withOrganization';
import DiscoverQuery from './discoverQuery';
+import WidgetChart from './widgetChart';
class Widget extends React.Component {
static propTypes = {
@@ -26,134 +23,62 @@ class Widget extends React.Component {
router: PropTypes.object,
};
- handleExportToDiscover = event => {
- const {organization, widget, router} = this.props;
- const [firstQuery] = widget.queries.discover;
- const {
- datetime,
- environments, // eslint-disable-line no-unused-vars
- ...selection
- } = this.props.selection;
-
- event.stopPropagation();
-
- // Discover does not support importing these
- const {
- groupby, // eslint-disable-line no-unused-vars
- rollup, // eslint-disable-line no-unused-vars
- orderby,
- ...query
- } = firstQuery;
-
- const orderbyTimeIndex = orderby.indexOf('time');
- let visual = 'table';
-
- if (orderbyTimeIndex !== -1) {
- query.orderby = `${orderbyTimeIndex === 0 ? '' : '-'}${query.aggregations[0][2]}`;
- visual = 'line-by-day';
- } else {
- query.orderby = orderby;
- }
-
- router.push(
- `/organizations/${organization.slug}/discover/${getQueryStringFromQuery({
- ...query,
- ...selection,
- start: datetime.start,
- end: datetime.end,
- range: datetime.period,
- limit: 1000,
- })}&visual=${visual}`
- );
- };
-
- renderResults(results) {
- const {releases, widget} = this.props;
- const isTable = widget.type === WIDGET_DISPLAY.TABLE;
-
- // get visualization based on widget data
- const ChartComponent = getChartComponent(widget);
- // get data func based on query
- const chartData = getData(results, widget);
-
- const extra = {
- ...(isTable && {
- headerProps: {hasButtons: true},
- extraTitle: this.renderDiscoverButton(),
- }),
- };
-
- if (widget.includeReleases) {
- return (
-
- {({releaseSeries}) => (
-
- )}
-
- );
- }
-
- return ;
- }
-
- renderDiscoverButton() {
- // TODO(billy): This is temporary
- // Need design followups
- return (
-
- );
- }
-
render() {
- const {widget} = this.props;
+ const {organization, router, widget, releases, selection} = this.props;
const {type, title, includePreviousPeriod, compareToPeriod, queries} = widget;
const isTable = type === WIDGET_DISPLAY.TABLE;
return (
- {({results}) => {
- if (!results) {
+ {({results, reloading}) => {
+ // Show a placeholder "square" during initial load
+ if (results === null) {
return ;
}
- if (isTable) {
- return this.renderResults(results);
- }
+ const widgetChartProps = {
+ releases,
+ selection,
+ results,
+ widget,
+ reloading,
+ router,
+ };
return (
-
-
- {title}
-
- {this.renderDiscoverButton()}
-
-
- {this.renderResults(results)}
-
+
+ {reloading && }
+ {isTable && }
+ {!isTable && (
+
+
+ {title}
+
+
+
+
+
+
+
+
+ )}
+
);
}}
);
}
}
+
export default withRouter(withOrganization(withGlobalSelection(Widget)));
export {Widget};
-// XXX Heights between panel headers with `hasButtons` are not equal :(
-const StyledPanelHeader = styled(PanelHeader)`
- height: 46px;
-`;
-
const StyledPanelBody = styled(PanelBody)`
height: 200px;
`;
@@ -162,3 +87,12 @@ const Placeholder = styled('div')`
background-color: ${p => p.theme.offWhite};
height: 248px;
`;
+
+const WidgetWrapper = styled('div')`
+ position: relative;
+`;
+
+const ReloadingMask = styled(LoadingMask)`
+ z-index: 1;
+ opacity: 0.6;
+`;
diff --git a/src/sentry/static/sentry/app/views/organizationDashboard/widgetChart.jsx b/src/sentry/static/sentry/app/views/organizationDashboard/widgetChart.jsx
new file mode 100644
index 00000000000000..1c6aa0719caecf
--- /dev/null
+++ b/src/sentry/static/sentry/app/views/organizationDashboard/widgetChart.jsx
@@ -0,0 +1,96 @@
+import {isEqual} from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {WIDGET_DISPLAY} from 'app/views/organizationDashboard/constants';
+import {getChartComponent} from 'app/views/organizationDashboard/utils/getChartComponent';
+import {getData} from 'app/views/organizationDashboard/utils/getData';
+import ChartZoom from 'app/components/charts/chartZoom';
+import ExploreWidget from 'app/views/organizationDashboard/exploreWidget';
+import ReleaseSeries from 'app/components/charts/releaseSeries';
+import SentryTypes from 'app/sentryTypes';
+
+/**
+ * Component that decides what Chart to render
+ * Extracted into another component so that we can use shouldComponentUpdate
+ */
+class WidgetChart extends React.Component {
+ static propTypes = {
+ router: PropTypes.object,
+ results: SentryTypes.DiscoverResults,
+ releases: PropTypes.arrayOf(SentryTypes.Release),
+ widget: SentryTypes.Widget,
+ organization: SentryTypes.Organization,
+ selection: SentryTypes.GlobalSelection,
+ };
+
+ shouldComponentUpdate(nextProps) {
+ if (nextProps.reloading) {
+ return false;
+ }
+
+ // It's not a big deal to re-render if this.prop.results == nextProps.results == []
+ const isDataEqual =
+ this.props.results.length &&
+ nextProps.results.length &&
+ isEqual(this.props.results[0].data, nextProps.results[0].data);
+
+ if (isDataEqual) {
+ return false;
+ }
+
+ return true;
+ }
+
+ renderZoomableChart(ChartComponent, props) {
+ const {router, selection} = this.props;
+ return (
+
+ {zoomRenderProps => }
+
+ );
+ }
+
+ render() {
+ const {results, releases, router, selection, widget} = this.props;
+ const isTable = widget.type === WIDGET_DISPLAY.TABLE;
+
+ // get visualization based on widget data
+ const ChartComponent = getChartComponent(widget);
+
+ // get data func based on query
+ const chartData = getData(results, widget);
+
+ const extra = {
+ ...(isTable && {
+ headerProps: {hasButtons: true},
+ extraTitle: ,
+ }),
+ };
+
+ // Releases can only be added to time charts
+ if (widget.includeReleases) {
+ return (
+
+ {({releaseSeries}) =>
+ this.renderZoomableChart(ChartComponent, {
+ ...chartData,
+ ...extra,
+ series: [...chartData.series, ...releaseSeries],
+ })}
+
+ );
+ }
+
+ if (chartData.isGroupedByDate) {
+ return this.renderZoomableChart(ChartComponent, {
+ ...chartData,
+ ...extra,
+ });
+ }
+
+ return ;
+ }
+}
+
+export default WidgetChart;
diff --git a/src/sentry/static/sentry/app/views/organizationEvents/eventsChart.jsx b/src/sentry/static/sentry/app/views/organizationEvents/eventsChart.jsx
index b7a1c4899b38e1..7afca44a94521f 100644
--- a/src/sentry/static/sentry/app/views/organizationEvents/eventsChart.jsx
+++ b/src/sentry/static/sentry/app/views/organizationEvents/eventsChart.jsx
@@ -1,8 +1,10 @@
import {isEqual} from 'lodash';
+import {withRouter} from 'react-router';
import PropTypes from 'prop-types';
import React from 'react';
import styled from 'react-emotion';
+import {getInterval} from 'app/components/charts/utils';
import {t} from 'app/locale';
import ChartZoom from 'app/components/charts/chartZoom';
import LineChart from 'app/components/charts/lineChart';
@@ -85,7 +87,7 @@ class EventsChart extends React.Component {
{...props}
api={api}
period={period}
- interval={interval}
+ interval={getInterval(this.props, true)}
showLoading={false}
query={query}
getCategory={DEFAULT_GET_CATEGORY}
@@ -124,27 +126,29 @@ class EventsChart extends React.Component {
}
}
-const EventsChartContainer = withGlobalSelection(
- withApi(
- class EventsChartWithParams extends React.Component {
- static propTypes = {
- selection: SentryTypes.GlobalSelection,
- };
-
- render() {
- const {selection, ...props} = this.props;
- const {datetime, projects, environments} = selection;
-
- return (
-
- );
+const EventsChartContainer = withRouter(
+ withGlobalSelection(
+ withApi(
+ class EventsChartWithParams extends React.Component {
+ static propTypes = {
+ selection: SentryTypes.GlobalSelection,
+ };
+
+ render() {
+ const {selection, ...props} = this.props;
+ const {datetime, projects, environments} = selection;
+
+ return (
+
+ );
+ }
}
- }
+ )
)
);
diff --git a/tests/js/spec/components/charts/utils.spec.jsx b/tests/js/spec/components/charts/utils.spec.jsx
new file mode 100644
index 00000000000000..ae96c8460f73bc
--- /dev/null
+++ b/tests/js/spec/components/charts/utils.spec.jsx
@@ -0,0 +1,54 @@
+import {getInterval, getDiffInMinutes} from 'app/components/charts/utils';
+
+describe('Chart Utils', function() {
+ describe('getInterval()', function() {
+ describe('with high fidelity', function() {
+ it('greater than 24 hours', function() {
+ expect(getInterval({period: '25h'}, true)).toBe('30m');
+ });
+
+ it('less than 30 minutes', function() {
+ expect(getInterval({period: '20m'}, true)).toBe('1m');
+ });
+ it('between 30 minutes and 24 hours', function() {
+ expect(getInterval({period: '12h'}, true)).toBe('5m');
+ });
+ });
+
+ describe('with low fidelity', function() {
+ it('greater than 24 hours', function() {
+ expect(getInterval({period: '25h'})).toBe('24h');
+ });
+
+ it('less than 30 minutes', function() {
+ expect(getInterval({period: '20m'})).toBe('5m');
+ });
+ it('between 30 minutes and 24 hours', function() {
+ expect(getInterval({period: '12h'})).toBe('15m');
+ });
+ });
+ });
+
+ describe('getDiffInMinutes()', function() {
+ describe('with period string', function() {
+ it('can parse a period string in seconds', function() {
+ expect(getDiffInMinutes({period: '30s'})).toBe(0.5);
+ });
+ it('can parse a period string in minutes', function() {
+ expect(getDiffInMinutes({period: '15m'})).toBe(15);
+ });
+ it('can parse a period string in hours', function() {
+ expect(getDiffInMinutes({period: '1h'})).toBe(60);
+ });
+ it('can parse a period string in days', function() {
+ expect(getDiffInMinutes({period: '5d'})).toBe(7200);
+ });
+ it('can parse a period string in weeks', function() {
+ expect(getDiffInMinutes({period: '1w'})).toBe(10080);
+ });
+ });
+
+ // This uses moment so we probably don't need to test it too extensively
+ describe('with absolute dates', function() {});
+ });
+});
diff --git a/tests/js/spec/views/organizationDashboard/dashboard.spec.jsx b/tests/js/spec/views/organizationDashboard/dashboard.spec.jsx
index cf379925927e5d..9f37f070b8afd7 100644
--- a/tests/js/spec/views/organizationDashboard/dashboard.spec.jsx
+++ b/tests/js/spec/views/organizationDashboard/dashboard.spec.jsx
@@ -39,7 +39,7 @@ describe('OrganizationDashboard', function() {
body: {
data: [],
meta: [],
- timing: [],
+ timing: {},
},
});
});
diff --git a/tests/js/spec/views/organizationDashboard/discoverQuery.spec.jsx b/tests/js/spec/views/organizationDashboard/discoverQuery.spec.jsx
new file mode 100644
index 00000000000000..8aded5c6e1b618
--- /dev/null
+++ b/tests/js/spec/views/organizationDashboard/discoverQuery.spec.jsx
@@ -0,0 +1,156 @@
+import {mount} from 'enzyme';
+import React from 'react';
+
+import {initializeOrg} from 'app-test/helpers/initializeOrg';
+import {mockRouterPush} from 'app-test/helpers/mockRouterPush';
+
+import DiscoverQuery from 'app/views/organizationDashboard/discoverQuery';
+
+describe('DiscoverQuery', function() {
+ const {organization, router, routerContext} = initializeOrg({
+ organization: {
+ features: ['sentry10', 'global-views'],
+ },
+ router: {
+ location: {
+ pathname: '/organizations/org-slug/dashboard/?statsPeriod=14d&utc=true',
+ query: {},
+ },
+ },
+ });
+ const widget = TestStubs.Widget();
+
+ let wrapper;
+ let discoverMock;
+ const renderMock = jest.fn(() => null);
+
+ beforeEach(function() {
+ renderMock.mockClear();
+ router.push.mockRestore();
+ MockApiClient.clearMockResponses();
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/environments/`,
+ body: TestStubs.Environments(),
+ });
+ discoverMock = MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/discover/query/',
+ method: 'POST',
+ body: {
+ data: [],
+ meta: [],
+ timing: {},
+ },
+ });
+ wrapper = mount(
+
+ {renderMock}
+ ,
+ routerContext
+ );
+ mockRouterPush(wrapper, router);
+ });
+
+ it('fetches data on mount', async function() {
+ expect(discoverMock).toHaveBeenCalledTimes(2);
+ await tick();
+ wrapper.update();
+
+ // First call is on mount which then fetches data
+ // Second call is when reloading = false
+ // Third call should have results
+ expect(renderMock).toHaveBeenCalledTimes(3);
+ expect(renderMock).toHaveBeenCalledWith(
+ expect.objectContaining({results: null, reloading: null})
+ );
+ expect(renderMock).toHaveBeenCalledWith(
+ expect.objectContaining({results: null, reloading: true})
+ );
+ expect(renderMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ results: [
+ {
+ data: [],
+ meta: [],
+ timing: {},
+ },
+ {
+ data: [],
+ meta: [],
+ timing: {},
+ },
+ ],
+ reloading: false,
+ })
+ );
+ });
+
+ it('re-renders if props.selection changes', function() {
+ renderMock.mockClear();
+ wrapper.setProps({selection: {datetime: {period: '7d'}}});
+ wrapper.update();
+
+ // Called twice because of fetchData (state.reloading)
+ expect(renderMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('re-renders if props.org changes', function() {
+ renderMock.mockClear();
+ wrapper.update();
+ expect(renderMock).toHaveBeenCalledTimes(0);
+
+ wrapper.setProps({organization: TestStubs.Organization()});
+ wrapper.update();
+
+ // Called twice because of fetchData (state.reloading)
+ expect(renderMock).toHaveBeenCalledTimes(2);
+ });
+
+ // I think this behavior can go away if necessary in the future
+ it('does not re-render if `props.queries` changes', function() {
+ renderMock.mockClear();
+ wrapper.setProps({queries: []});
+ wrapper.update();
+
+ expect(renderMock).toHaveBeenCalledTimes(0);
+ });
+
+ it('does not re-render if `props.children` "changes" (e.g. new function instance gets passed every render)', function() {
+ renderMock.mockClear();
+ let newRender = jest.fn(() => null);
+ wrapper.setProps({children: newRender});
+ wrapper.update();
+
+ expect(renderMock).toHaveBeenCalledTimes(0);
+ });
+
+ it('has the right period and rollup queries when we include previous period', function() {
+ renderMock.mockClear();
+ wrapper = mount(
+
+ {renderMock}
+ ,
+ routerContext
+ );
+ mockRouterPush(wrapper, router);
+
+ expect(renderMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queries: [
+ expect.objectContaining({range: '24h'}),
+ expect.objectContaining({range: '24h'}),
+ ],
+ })
+ );
+ });
+});
diff --git a/tests/js/spec/views/organizationDashboard/widgetChart.spec.jsx b/tests/js/spec/views/organizationDashboard/widgetChart.spec.jsx
new file mode 100644
index 00000000000000..7037f44c061840
--- /dev/null
+++ b/tests/js/spec/views/organizationDashboard/widgetChart.spec.jsx
@@ -0,0 +1,149 @@
+import {mount} from 'enzyme';
+import React from 'react';
+
+import {initializeOrg} from 'app-test/helpers/initializeOrg';
+
+import WidgetChart from 'app/views/organizationDashboard/widgetChart';
+
+describe('WidgetChart', function() {
+ const {organization, router, routerContext} = initializeOrg({
+ organization: {
+ features: ['sentry10', 'global-views'],
+ },
+ router: {
+ location: {
+ pathname: '/organizations/org-slug/dashboard/?statsPeriod=14d&utc=true',
+ query: {},
+ },
+ },
+ });
+
+ let wrapper;
+ const renderMock = jest.fn(() => null);
+
+ const TIME_QUERY = {
+ fields: [],
+ aggregations: [['count()', '', 'count']],
+ orderby: '-time',
+ groupby: ['time'],
+ limit: 1000,
+ };
+
+ const MAP_QUERY = {
+ fields: ['geo.country_code'],
+ conditions: [['geo.country_code', 'IS NOT NULL', null]],
+ aggregations: [['count()', null, 'count']],
+ limit: 10,
+
+ orderby: '-count',
+ groupby: ['geo.country_code'],
+ };
+
+ beforeEach(function() {
+ renderMock.mockClear();
+ router.push.mockRestore();
+ MockApiClient.clearMockResponses();
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/environments/`,
+ body: TestStubs.Environments(),
+ });
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/discover/query/',
+ method: 'POST',
+ body: {
+ data: [],
+ meta: [],
+ timing: {},
+ },
+ });
+ });
+
+ it('renders zoomable time chart', async function() {
+ wrapper = mount(
+ ,
+ routerContext
+ );
+
+ expect(wrapper.find('LineChart')).toHaveLength(1);
+ expect(wrapper.find('ChartZoom')).toHaveLength(1);
+ });
+
+ it('renders time chart with series', async function() {
+ wrapper = mount(
+ ,
+ routerContext
+ );
+
+ expect(wrapper.find('ReleaseSeries')).toHaveLength(1);
+ });
+
+ it('renders non-zoomable non-time chart', async function() {
+ wrapper = mount(
+ ,
+ routerContext
+ );
+
+ expect(wrapper.find('WorldMapChart')).toHaveLength(1);
+ expect(wrapper.find('ChartZoom')).toHaveLength(0);
+ });
+
+ it('update only if data is not reloading and data has changed', async function() {
+ wrapper = mount(
+ ,
+ routerContext
+ );
+
+ const renderSpy = jest.spyOn(wrapper.find('WorldMapChart').instance(), 'render');
+
+ wrapper.setProps({reloading: true});
+ wrapper.update();
+ expect(renderSpy).toHaveBeenCalledTimes(0);
+
+ wrapper.setProps({reloading: false, results: [{data: [{time: 1, count: 2}]}]});
+ wrapper.update();
+ expect(renderSpy).toHaveBeenCalledTimes(1);
+ });
+});