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); + }); +});