chart.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. import {Component} from 'react';
  2. import {InjectedRouter} from 'react-router';
  3. import {withTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {DataZoomComponentOption, LegendComponentOption} from 'echarts';
  6. import {Location} from 'history';
  7. import isEqual from 'lodash/isEqual';
  8. import omit from 'lodash/omit';
  9. import {AreaChart} from 'sentry/components/charts/areaChart';
  10. import {BarChart} from 'sentry/components/charts/barChart';
  11. import ChartZoom from 'sentry/components/charts/chartZoom';
  12. import ErrorPanel from 'sentry/components/charts/errorPanel';
  13. import {LineChart} from 'sentry/components/charts/lineChart';
  14. import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
  15. import TransitionChart from 'sentry/components/charts/transitionChart';
  16. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  17. import {getSeriesSelection, processTableResults} from 'sentry/components/charts/utils';
  18. import {WorldMapChart} from 'sentry/components/charts/worldMapChart';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import Placeholder, {PlaceholderProps} from 'sentry/components/placeholder';
  21. import Tooltip from 'sentry/components/tooltip';
  22. import {IconWarning} from 'sentry/icons';
  23. import space from 'sentry/styles/space';
  24. import {Organization, PageFilters} from 'sentry/types';
  25. import {EChartDataZoomHandler, EChartEventHandler} from 'sentry/types/echarts';
  26. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  27. import {getFieldFormatter} from 'sentry/utils/discover/fieldRenderers';
  28. import {
  29. getAggregateArg,
  30. getEquation,
  31. getMeasurementSlug,
  32. isEquation,
  33. maybeEquationAlias,
  34. stripDerivedMetricsPrefix,
  35. stripEquationPrefix,
  36. } from 'sentry/utils/discover/fields';
  37. import getDynamicText from 'sentry/utils/getDynamicText';
  38. import {Theme} from 'sentry/utils/theme';
  39. import {eventViewFromWidget} from 'sentry/views/dashboardsV2/utils';
  40. import {getDatasetConfig} from '../datasetConfig/base';
  41. import {DisplayType, Widget, WidgetType} from '../types';
  42. import {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
  43. const OTHER = 'Other';
  44. export const SLIDER_HEIGHT = 60;
  45. export type AugmentedEChartDataZoomHandler = (
  46. params: Parameters<EChartDataZoomHandler>[0] & {
  47. seriesEnd: string | number;
  48. seriesStart: string | number;
  49. },
  50. instance: Parameters<EChartDataZoomHandler>[1]
  51. ) => void;
  52. type TableResultProps = Pick<
  53. GenericWidgetQueriesChildrenProps,
  54. 'errorMessage' | 'loading' | 'tableResults'
  55. >;
  56. type WidgetCardChartProps = Pick<
  57. GenericWidgetQueriesChildrenProps,
  58. 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
  59. > & {
  60. location: Location;
  61. organization: Organization;
  62. router: InjectedRouter;
  63. selection: PageFilters;
  64. theme: Theme;
  65. widget: Widget;
  66. chartZoomOptions?: DataZoomComponentOption;
  67. expandNumbers?: boolean;
  68. isMobile?: boolean;
  69. legendOptions?: LegendComponentOption;
  70. noPadding?: boolean;
  71. onLegendSelectChanged?: EChartEventHandler<{
  72. name: string;
  73. selected: Record<string, boolean>;
  74. type: 'legendselectchanged';
  75. }>;
  76. onZoom?: AugmentedEChartDataZoomHandler;
  77. showSlider?: boolean;
  78. windowWidth?: number;
  79. };
  80. type State = {
  81. // For tracking height of the container wrapping BigNumber widgets
  82. // so we can dynamically scale font-size
  83. containerHeight: number;
  84. };
  85. class WidgetCardChart extends Component<WidgetCardChartProps, State> {
  86. state = {containerHeight: 0};
  87. shouldComponentUpdate(nextProps: WidgetCardChartProps, nextState: State): boolean {
  88. if (
  89. this.props.widget.displayType === DisplayType.BIG_NUMBER &&
  90. nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
  91. (this.props.windowWidth !== nextProps.windowWidth ||
  92. !isEqual(this.props.widget?.layout, nextProps.widget?.layout))
  93. ) {
  94. return true;
  95. }
  96. // Widget title changes should not update the WidgetCardChart component tree
  97. const currentProps = {
  98. ...omit(this.props, ['windowWidth']),
  99. widget: {
  100. ...this.props.widget,
  101. title: '',
  102. },
  103. };
  104. nextProps = {
  105. ...omit(nextProps, ['windowWidth']),
  106. widget: {
  107. ...nextProps.widget,
  108. title: '',
  109. },
  110. };
  111. return !isEqual(currentProps, nextProps) || !isEqual(this.state, nextState);
  112. }
  113. tableResultComponent({
  114. loading,
  115. errorMessage,
  116. tableResults,
  117. }: TableResultProps): React.ReactNode {
  118. const {location, widget, organization, selection} = this.props;
  119. if (errorMessage) {
  120. return (
  121. <StyledErrorPanel>
  122. <IconWarning color="gray500" size="lg" />
  123. </StyledErrorPanel>
  124. );
  125. }
  126. if (typeof tableResults === 'undefined') {
  127. // Align height to other charts.
  128. return <LoadingPlaceholder />;
  129. }
  130. const datasetConfig = getDatasetConfig(widget.widgetType);
  131. return tableResults.map((result, i) => {
  132. const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
  133. const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
  134. const eventView = eventViewFromWidget(
  135. widget.title,
  136. widget.queries[0],
  137. selection,
  138. widget.displayType
  139. );
  140. return (
  141. <StyledSimpleTableChart
  142. key={`table:${result.title}`}
  143. eventView={eventView}
  144. fieldAliases={fieldAliases}
  145. location={location}
  146. fields={fields}
  147. title={tableResults.length > 1 ? result.title : ''}
  148. loading={loading}
  149. loader={<LoadingPlaceholder />}
  150. metadata={result.meta}
  151. data={result.data}
  152. organization={organization}
  153. stickyHeaders
  154. getCustomFieldRenderer={datasetConfig.getCustomFieldRenderer}
  155. />
  156. );
  157. });
  158. }
  159. bigNumberComponent({
  160. loading,
  161. errorMessage,
  162. tableResults,
  163. }: TableResultProps): React.ReactNode {
  164. if (errorMessage) {
  165. return (
  166. <StyledErrorPanel>
  167. <IconWarning color="gray500" size="lg" />
  168. </StyledErrorPanel>
  169. );
  170. }
  171. if (typeof tableResults === 'undefined' || loading) {
  172. return <BigNumber>{'\u2014'}</BigNumber>;
  173. }
  174. const {containerHeight} = this.state;
  175. const {location, organization, widget, isMobile, expandNumbers} = this.props;
  176. const isAlias =
  177. !organization.features.includes('discover-frontend-use-events-endpoint') &&
  178. widget.widgetType !== WidgetType.RELEASE;
  179. return tableResults.map(result => {
  180. const tableMeta = {...result.meta};
  181. const fields = Object.keys(tableMeta);
  182. const field = fields[0];
  183. // Change tableMeta for the field from integer to string since we will be rendering with toLocaleString
  184. const shouldExpandInteger = !!expandNumbers && tableMeta[field] === 'integer';
  185. if (shouldExpandInteger) {
  186. tableMeta[field] = 'string';
  187. }
  188. if (!field || !result.data?.length) {
  189. return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
  190. }
  191. const dataRow = result.data[0];
  192. const fieldRenderer = getFieldFormatter(field, tableMeta, isAlias);
  193. const unit = tableMeta.units?.[field];
  194. const rendered = fieldRenderer(
  195. shouldExpandInteger ? {[field]: dataRow[field].toLocaleString()} : dataRow,
  196. {location, organization, unit}
  197. );
  198. const isModalWidget = !!!(widget.id || widget.tempId);
  199. if (
  200. !!!organization.features.includes('dashboard-grid-layout') ||
  201. isModalWidget ||
  202. isMobile
  203. ) {
  204. return <BigNumber key={`big_number:${result.title}`}>{rendered}</BigNumber>;
  205. }
  206. // The font size is the container height, minus the top and bottom padding
  207. const fontSize = !!!expandNumbers
  208. ? containerHeight - parseInt(space(1), 10) - parseInt(space(3), 10)
  209. : `max(min(8vw, 90px), ${space(4)})`;
  210. return (
  211. <BigNumber
  212. key={`big_number:${result.title}`}
  213. style={{
  214. fontSize,
  215. ...(!!expandNumbers
  216. ? {padding: `${space(1)} ${space(3)} 0 ${space(3)}`}
  217. : {}),
  218. }}
  219. >
  220. <Tooltip title={rendered} showOnlyOnOverflow>
  221. {rendered}
  222. </Tooltip>
  223. </BigNumber>
  224. );
  225. });
  226. }
  227. chartComponent(chartProps): React.ReactNode {
  228. const {organization, widget} = this.props;
  229. const stacked =
  230. organization.features.includes('new-widget-builder-experience-design') &&
  231. widget.queries[0].columns.length > 0;
  232. switch (widget.displayType) {
  233. case 'bar':
  234. return <BarChart {...chartProps} stacked={stacked} />;
  235. case 'area':
  236. case 'top_n':
  237. return <AreaChart stacked {...chartProps} />;
  238. case 'world_map':
  239. return <WorldMapChart {...chartProps} />;
  240. case 'line':
  241. default:
  242. return <LineChart {...chartProps} />;
  243. }
  244. }
  245. render() {
  246. const {
  247. theme,
  248. tableResults,
  249. timeseriesResults,
  250. errorMessage,
  251. loading,
  252. widget,
  253. organization,
  254. onZoom,
  255. legendOptions,
  256. expandNumbers,
  257. showSlider,
  258. noPadding,
  259. chartZoomOptions,
  260. } = this.props;
  261. if (widget.displayType === 'table') {
  262. return getDynamicText({
  263. value: (
  264. <TransitionChart loading={loading} reloading={loading}>
  265. <LoadingScreen loading={loading} />
  266. {this.tableResultComponent({tableResults, loading, errorMessage})}
  267. </TransitionChart>
  268. ),
  269. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  270. });
  271. }
  272. if (widget.displayType === 'big_number') {
  273. return (
  274. <TransitionChart loading={loading} reloading={loading}>
  275. <LoadingScreen loading={loading} />
  276. <BigNumberResizeWrapper
  277. ref={el => {
  278. if (el !== null && !!!expandNumbers) {
  279. const {height} = el.getBoundingClientRect();
  280. if (height !== this.state.containerHeight) {
  281. this.setState({containerHeight: height});
  282. }
  283. }
  284. }}
  285. >
  286. {this.bigNumberComponent({tableResults, loading, errorMessage})}
  287. </BigNumberResizeWrapper>
  288. </TransitionChart>
  289. );
  290. }
  291. if (errorMessage) {
  292. return (
  293. <StyledErrorPanel>
  294. <IconWarning color="gray500" size="lg" />
  295. </StyledErrorPanel>
  296. );
  297. }
  298. const {location, router, selection, onLegendSelectChanged} = this.props;
  299. const {start, end, period, utc} = selection.datetime;
  300. // Only allow height resizing for widgets that are on a dashboard
  301. const autoHeightResize = Boolean(
  302. organization.features.includes('dashboard-grid-layout') &&
  303. (widget.id || widget.tempId)
  304. );
  305. if (widget.displayType === 'world_map') {
  306. const {data, title} = processTableResults(tableResults);
  307. const series = [
  308. {
  309. seriesName: title,
  310. data,
  311. },
  312. ];
  313. return (
  314. <TransitionChart loading={loading} reloading={loading}>
  315. <LoadingScreen loading={loading} />
  316. <ChartWrapper autoHeightResize={autoHeightResize}>
  317. {getDynamicText({
  318. value: this.chartComponent({
  319. series,
  320. autoHeightResize,
  321. }),
  322. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  323. })}
  324. </ChartWrapper>
  325. </TransitionChart>
  326. );
  327. }
  328. const legend = {
  329. left: 0,
  330. top: 0,
  331. selected: getSeriesSelection(location),
  332. formatter: (seriesName: string) => {
  333. const arg = getAggregateArg(seriesName);
  334. if (arg !== null) {
  335. const slug = getMeasurementSlug(arg);
  336. if (slug !== null) {
  337. seriesName = slug.toUpperCase();
  338. }
  339. }
  340. if (maybeEquationAlias(seriesName)) {
  341. seriesName = stripEquationPrefix(seriesName);
  342. }
  343. return seriesName;
  344. },
  345. ...legendOptions,
  346. };
  347. const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
  348. const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
  349. const chartOptions = {
  350. autoHeightResize,
  351. grid: {
  352. left: 0,
  353. right: 4,
  354. top: '40px',
  355. bottom: showSlider ? SLIDER_HEIGHT : 0,
  356. },
  357. seriesOptions: {
  358. showSymbol: false,
  359. },
  360. tooltip: {
  361. trigger: 'axis',
  362. valueFormatter: tooltipFormatter,
  363. },
  364. yAxis: {
  365. axisLabel: {
  366. color: theme.chartLabel,
  367. formatter: (value: number) => axisLabelFormatter(value, axisLabel),
  368. },
  369. },
  370. };
  371. return (
  372. <ChartZoom
  373. router={router}
  374. period={period}
  375. start={start}
  376. end={end}
  377. utc={utc}
  378. showSlider={showSlider}
  379. chartZoomOptions={chartZoomOptions}
  380. >
  381. {zoomRenderProps => {
  382. if (errorMessage) {
  383. return (
  384. <StyledErrorPanel>
  385. <IconWarning color="gray500" size="lg" />
  386. </StyledErrorPanel>
  387. );
  388. }
  389. const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
  390. const shouldColorOther = timeseriesResults?.some(
  391. ({seriesName}) => seriesName && seriesName.match(otherRegex)
  392. );
  393. const colors = timeseriesResults
  394. ? theme.charts.getColorPalette(
  395. timeseriesResults.length - (shouldColorOther ? 3 : 2)
  396. )
  397. : [];
  398. // TODO(wmak): Need to change this when updating dashboards to support variable topEvents
  399. if (shouldColorOther) {
  400. colors[colors.length] = theme.chartOther;
  401. }
  402. // Create a list of series based on the order of the fields,
  403. const series = timeseriesResults
  404. ? timeseriesResults.map((values, i: number) => {
  405. let seriesName = '';
  406. if (values.seriesName !== undefined) {
  407. seriesName = isEquation(values.seriesName)
  408. ? getEquation(values.seriesName)
  409. : values.seriesName;
  410. }
  411. return {
  412. ...values,
  413. seriesName,
  414. color: colors[i],
  415. };
  416. })
  417. : [];
  418. const seriesStart = series[0]?.data[0]?.name;
  419. const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
  420. return (
  421. <TransitionChart loading={loading} reloading={loading}>
  422. <LoadingScreen loading={loading} />
  423. <ChartWrapper autoHeightResize={autoHeightResize} noPadding={noPadding}>
  424. {getDynamicText({
  425. value: this.chartComponent({
  426. ...zoomRenderProps,
  427. ...chartOptions,
  428. // Override default datazoom behaviour for updating Global Selection Header
  429. ...(onZoom
  430. ? {
  431. onDataZoom: (evt, chartProps) =>
  432. // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
  433. // callback with percentage instead of datetime values. Passing seriesStart
  434. // and seriesEnd allows calculating datetime values with percentage.
  435. onZoom({...evt, seriesStart, seriesEnd}, chartProps),
  436. }
  437. : {}),
  438. legend,
  439. series,
  440. onLegendSelectChanged,
  441. }),
  442. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  443. })}
  444. </ChartWrapper>
  445. </TransitionChart>
  446. );
  447. }}
  448. </ChartZoom>
  449. );
  450. }
  451. }
  452. export default withTheme(WidgetCardChart);
  453. const StyledTransparentLoadingMask = styled(props => (
  454. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  455. ))`
  456. display: flex;
  457. justify-content: center;
  458. align-items: center;
  459. `;
  460. const LoadingScreen = ({loading}: {loading: boolean}) => {
  461. if (!loading) {
  462. return null;
  463. }
  464. return (
  465. <StyledTransparentLoadingMask visible={loading}>
  466. <LoadingIndicator mini />
  467. </StyledTransparentLoadingMask>
  468. );
  469. };
  470. const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
  471. <Placeholder height="200px" className={className} />
  472. ))`
  473. background-color: ${p => p.theme.surface200};
  474. `;
  475. const BigNumberResizeWrapper = styled('div')`
  476. height: 100%;
  477. width: 100%;
  478. overflow: hidden;
  479. `;
  480. const BigNumber = styled('div')`
  481. line-height: 1;
  482. display: inline-flex;
  483. flex: 1;
  484. width: 100%;
  485. min-height: 0;
  486. font-size: 32px;
  487. color: ${p => p.theme.headingColor};
  488. padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
  489. * {
  490. text-align: left !important;
  491. }
  492. `;
  493. const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
  494. ${p => p.autoHeightResize && 'height: 100%;'}
  495. padding: ${p => (!!p.noPadding ? `0` : `0 ${space(3)} ${space(3)}`)};
  496. `;
  497. const StyledSimpleTableChart = styled(SimpleTableChart)`
  498. margin-top: ${space(1.5)};
  499. border-bottom-left-radius: ${p => p.theme.borderRadius};
  500. border-bottom-right-radius: ${p => p.theme.borderRadius};
  501. font-size: ${p => p.theme.fontSizeMedium};
  502. box-shadow: none;
  503. `;
  504. const StyledErrorPanel = styled(ErrorPanel)`
  505. padding: ${space(2)};
  506. `;