chart.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. import type React from 'react';
  2. import {Component} from 'react';
  3. import type {Theme} from '@emotion/react';
  4. import {withTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import type {DataZoomComponentOption, LegendComponentOption} from 'echarts';
  7. import type {Location} from 'history';
  8. import isEqual from 'lodash/isEqual';
  9. import omit from 'lodash/omit';
  10. import {AreaChart} from 'sentry/components/charts/areaChart';
  11. import {BarChart} from 'sentry/components/charts/barChart';
  12. import ChartZoom from 'sentry/components/charts/chartZoom';
  13. import ErrorPanel from 'sentry/components/charts/errorPanel';
  14. import {LineChart} from 'sentry/components/charts/lineChart';
  15. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  16. import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
  17. import TransitionChart from 'sentry/components/charts/transitionChart';
  18. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  19. import {getSeriesSelection, isChartHovered} from 'sentry/components/charts/utils';
  20. import LoadingIndicator from 'sentry/components/loadingIndicator';
  21. import type {PlaceholderProps} from 'sentry/components/placeholder';
  22. import Placeholder from 'sentry/components/placeholder';
  23. import {IconWarning} from 'sentry/icons';
  24. import {space} from 'sentry/styles/space';
  25. import type {PageFilters} from 'sentry/types/core';
  26. import type {
  27. EChartDataZoomHandler,
  28. EChartEventHandler,
  29. ReactEchartsRef,
  30. } from 'sentry/types/echarts';
  31. import type {Organization} from 'sentry/types/organization';
  32. import {defined} from 'sentry/utils';
  33. import {
  34. axisLabelFormatter,
  35. axisLabelFormatterUsingAggregateOutputType,
  36. getDurationUnit,
  37. tooltipFormatter,
  38. } from 'sentry/utils/discover/charts';
  39. import type {EventsMetaType} from 'sentry/utils/discover/eventView';
  40. import type {AggregationOutputType} from 'sentry/utils/discover/fields';
  41. import {
  42. aggregateOutputType,
  43. getAggregateArg,
  44. getEquation,
  45. getMeasurementSlug,
  46. isEquation,
  47. maybeEquationAlias,
  48. stripDerivedMetricsPrefix,
  49. stripEquationPrefix,
  50. } from 'sentry/utils/discover/fields';
  51. import getDynamicText from 'sentry/utils/getDynamicText';
  52. import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
  53. import {getBucketSize} from 'sentry/views/dashboards/widgetCard/utils';
  54. import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
  55. import {getFormatter} from '../../../components/charts/components/tooltip';
  56. import {getDatasetConfig} from '../datasetConfig/base';
  57. import type {Widget} from '../types';
  58. import {DisplayType} from '../types';
  59. import type WidgetLegendSelectionState from '../widgetLegendSelectionState';
  60. import {BigNumberWidgetVisualization} from '../widgets/bigNumberWidget/bigNumberWidgetVisualization';
  61. import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
  62. const OTHER = 'Other';
  63. const PERCENTAGE_DECIMAL_POINTS = 3;
  64. export const SLIDER_HEIGHT = 60;
  65. export type AugmentedEChartDataZoomHandler = (
  66. params: Parameters<EChartDataZoomHandler>[0] & {
  67. seriesEnd: string | number;
  68. seriesStart: string | number;
  69. },
  70. instance: Parameters<EChartDataZoomHandler>[1]
  71. ) => void;
  72. type TableResultProps = Pick<
  73. GenericWidgetQueriesChildrenProps,
  74. 'errorMessage' | 'loading' | 'tableResults'
  75. >;
  76. type WidgetCardChartProps = Pick<
  77. GenericWidgetQueriesChildrenProps,
  78. 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
  79. > & {
  80. location: Location;
  81. organization: Organization;
  82. selection: PageFilters;
  83. theme: Theme;
  84. widget: Widget;
  85. widgetLegendState: WidgetLegendSelectionState;
  86. chartGroup?: string;
  87. chartZoomOptions?: DataZoomComponentOption;
  88. expandNumbers?: boolean;
  89. isMobile?: boolean;
  90. legendOptions?: LegendComponentOption;
  91. noPadding?: boolean;
  92. onLegendSelectChanged?: EChartEventHandler<{
  93. name: string;
  94. selected: Record<string, boolean>;
  95. type: 'legendselectchanged';
  96. }>;
  97. onZoom?: AugmentedEChartDataZoomHandler;
  98. shouldResize?: boolean;
  99. showSlider?: boolean;
  100. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  101. windowWidth?: number;
  102. };
  103. class WidgetCardChart extends Component<WidgetCardChartProps> {
  104. shouldComponentUpdate(nextProps: WidgetCardChartProps): boolean {
  105. if (
  106. this.props.widget.displayType === DisplayType.BIG_NUMBER &&
  107. nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
  108. (this.props.windowWidth !== nextProps.windowWidth ||
  109. !isEqual(this.props.widget?.layout, nextProps.widget?.layout))
  110. ) {
  111. return true;
  112. }
  113. // Widget title changes should not update the WidgetCardChart component tree
  114. const currentProps = {
  115. ...omit(this.props, ['windowWidth']),
  116. widget: {
  117. ...this.props.widget,
  118. title: '',
  119. },
  120. };
  121. nextProps = {
  122. ...omit(nextProps, ['windowWidth']),
  123. widget: {
  124. ...nextProps.widget,
  125. title: '',
  126. },
  127. };
  128. return !isEqual(currentProps, nextProps);
  129. }
  130. tableResultComponent({
  131. loading,
  132. errorMessage,
  133. tableResults,
  134. }: TableResultProps): React.ReactNode {
  135. const {location, widget, selection} = this.props;
  136. if (errorMessage) {
  137. return (
  138. <StyledErrorPanel>
  139. <IconWarning color="gray500" size="lg" />
  140. </StyledErrorPanel>
  141. );
  142. }
  143. if (typeof tableResults === 'undefined') {
  144. // Align height to other charts.
  145. return <LoadingPlaceholder />;
  146. }
  147. const datasetConfig = getDatasetConfig(widget.widgetType);
  148. return tableResults.map((result, i) => {
  149. const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
  150. const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
  151. const eventView = eventViewFromWidget(widget.title, widget.queries[0], selection);
  152. return (
  153. <TableWrapper key={`table:${result.title}`}>
  154. <StyledSimpleTableChart
  155. eventView={eventView}
  156. fieldAliases={fieldAliases}
  157. location={location}
  158. fields={fields}
  159. title={tableResults.length > 1 ? result.title : ''}
  160. loading={loading}
  161. loader={<LoadingPlaceholder />}
  162. metadata={result.meta}
  163. data={result.data}
  164. stickyHeaders
  165. fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
  166. getCustomFieldRenderer={datasetConfig.getCustomFieldRenderer}
  167. />
  168. </TableWrapper>
  169. );
  170. });
  171. }
  172. bigNumberComponent({
  173. loading,
  174. errorMessage,
  175. tableResults,
  176. }: TableResultProps): React.ReactNode {
  177. if (errorMessage) {
  178. return (
  179. <StyledErrorPanel>
  180. <IconWarning color="gray500" size="lg" />
  181. </StyledErrorPanel>
  182. );
  183. }
  184. if (typeof tableResults === 'undefined' || loading) {
  185. return <BigNumber>{'\u2014'}</BigNumber>;
  186. }
  187. const {widget} = this.props;
  188. return tableResults.map((result, i) => {
  189. const tableMeta = {...result.meta};
  190. const fields = Object.keys(tableMeta?.fields ?? {});
  191. let field = fields[0];
  192. let selectedField = field;
  193. if (defined(widget.queries[0].selectedAggregate)) {
  194. const index = widget.queries[0].selectedAggregate;
  195. selectedField = widget.queries[0].aggregates[index];
  196. if (fields.includes(selectedField)) {
  197. field = selectedField;
  198. }
  199. }
  200. const data = result?.data;
  201. const meta = result?.meta as EventsMetaType;
  202. const value = data?.[0]?.[selectedField];
  203. if (
  204. !field ||
  205. !result.data?.length ||
  206. selectedField === 'equation|' ||
  207. selectedField === '' ||
  208. !defined(value) ||
  209. !Number.isFinite(value) ||
  210. Number.isNaN(value)
  211. ) {
  212. return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
  213. }
  214. return (
  215. <BigNumberWidgetVisualization
  216. key={i}
  217. field={field}
  218. value={value}
  219. meta={meta}
  220. thresholds={widget.thresholds ?? undefined}
  221. preferredPolarity="-"
  222. />
  223. );
  224. });
  225. }
  226. chartRef: ReactEchartsRef | null = null;
  227. handleRef = (chartRef: ReactEchartsRef): void => {
  228. if (chartRef && !this.chartRef) {
  229. this.chartRef = chartRef;
  230. // add chart to the group so that it has synced cursors
  231. const instance = chartRef.getEchartsInstance?.();
  232. if (instance && !instance.group && this.props.chartGroup) {
  233. instance.group = this.props.chartGroup;
  234. }
  235. }
  236. if (!chartRef) {
  237. this.chartRef = null;
  238. }
  239. };
  240. chartComponent(chartProps): React.ReactNode {
  241. const {widget} = this.props;
  242. const stacked = widget.queries[0]?.columns.length > 0;
  243. switch (widget.displayType) {
  244. case 'bar':
  245. return <BarChart {...chartProps} stacked={stacked} />;
  246. case 'area':
  247. case 'top_n':
  248. return <AreaChart stacked {...chartProps} />;
  249. case 'line':
  250. default:
  251. return <LineChart {...chartProps} />;
  252. }
  253. }
  254. render() {
  255. const {
  256. theme,
  257. tableResults,
  258. timeseriesResults,
  259. errorMessage,
  260. loading,
  261. widget,
  262. onZoom,
  263. legendOptions,
  264. showSlider,
  265. noPadding,
  266. chartZoomOptions,
  267. timeseriesResultsTypes,
  268. shouldResize,
  269. organization,
  270. } = this.props;
  271. if (widget.displayType === 'table') {
  272. return getDynamicText({
  273. value: (
  274. <TransitionChart loading={loading} reloading={loading}>
  275. <LoadingScreen loading={loading} />
  276. {this.tableResultComponent({tableResults, loading, errorMessage})}
  277. </TransitionChart>
  278. ),
  279. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  280. });
  281. }
  282. if (widget.displayType === 'big_number') {
  283. return (
  284. <TransitionChart loading={loading} reloading={loading}>
  285. <LoadingScreen loading={loading} />
  286. <BigNumberResizeWrapper>
  287. {this.bigNumberComponent({tableResults, loading, errorMessage})}
  288. </BigNumberResizeWrapper>
  289. </TransitionChart>
  290. );
  291. }
  292. if (errorMessage) {
  293. return (
  294. <StyledErrorPanel>
  295. <IconWarning color="gray500" size="lg" />
  296. </StyledErrorPanel>
  297. );
  298. }
  299. const {location, selection, onLegendSelectChanged, widgetLegendState} = this.props;
  300. const {start, end, period, utc} = selection.datetime;
  301. const {projects, environments} = selection;
  302. const legend = {
  303. left: 0,
  304. top: 0,
  305. selected: getSeriesSelection(location),
  306. formatter: (seriesName: string) => {
  307. seriesName = WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName);
  308. const arg = getAggregateArg(seriesName);
  309. if (arg !== null) {
  310. const slug = getMeasurementSlug(arg);
  311. if (slug !== null) {
  312. seriesName = slug.toUpperCase();
  313. }
  314. }
  315. if (maybeEquationAlias(seriesName)) {
  316. seriesName = stripEquationPrefix(seriesName);
  317. }
  318. return seriesName;
  319. },
  320. ...legendOptions,
  321. };
  322. const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
  323. const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
  324. // Check to see if all series output types are the same. If not, then default to number.
  325. const outputType =
  326. timeseriesResultsTypes && new Set(Object.values(timeseriesResultsTypes)).size === 1
  327. ? timeseriesResultsTypes[axisLabel]
  328. : 'number';
  329. const isDurationChart = outputType === 'duration';
  330. const durationUnit = isDurationChart
  331. ? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
  332. : undefined;
  333. const bucketSize = getBucketSize(timeseriesResults);
  334. const valueFormatter = (value: number, seriesName?: string) => {
  335. const decodedSeriesName = seriesName
  336. ? WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName)
  337. : seriesName;
  338. const aggregateName = decodedSeriesName?.split(':').pop()?.trim();
  339. if (aggregateName) {
  340. return timeseriesResultsTypes
  341. ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
  342. : tooltipFormatter(value, aggregateOutputType(aggregateName));
  343. }
  344. return tooltipFormatter(value, 'number');
  345. };
  346. const nameFormatter = (name: string) => {
  347. return WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(name);
  348. };
  349. const chartOptions = {
  350. autoHeightResize: shouldResize ?? true,
  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. axisPointer: {
  363. type: 'cross',
  364. },
  365. formatter: (params, asyncTicket) => {
  366. const {chartGroup} = this.props;
  367. const isInGroup =
  368. chartGroup && chartGroup === this.chartRef?.getEchartsInstance().group;
  369. // tooltip is triggered whenever any chart in the group is hovered,
  370. // so we need to check if the mouse is actually over this chart
  371. if (isInGroup && !isChartHovered(this.chartRef)) {
  372. return '';
  373. }
  374. return getFormatter({
  375. valueFormatter,
  376. nameFormatter,
  377. isGroupedByDate: true,
  378. bucketSize,
  379. addSecondsToTimeFormat: false,
  380. showTimeInTooltip: true,
  381. })(params, asyncTicket);
  382. },
  383. },
  384. yAxis: {
  385. axisLabel: {
  386. color: theme.chartLabel,
  387. formatter: (value: number) => {
  388. if (timeseriesResultsTypes) {
  389. return axisLabelFormatterUsingAggregateOutputType(
  390. value,
  391. outputType,
  392. true,
  393. durationUnit,
  394. undefined,
  395. PERCENTAGE_DECIMAL_POINTS
  396. );
  397. }
  398. return axisLabelFormatter(
  399. value,
  400. aggregateOutputType(axisLabel),
  401. true,
  402. undefined,
  403. undefined,
  404. PERCENTAGE_DECIMAL_POINTS
  405. );
  406. },
  407. },
  408. axisPointer: {
  409. type: 'line',
  410. snap: false,
  411. lineStyle: {
  412. type: 'solid',
  413. width: 0.5,
  414. },
  415. label: {
  416. show: false,
  417. },
  418. },
  419. minInterval: durationUnit ?? 0,
  420. },
  421. xAxis: {
  422. axisPointer: {
  423. snap: true,
  424. },
  425. },
  426. };
  427. return (
  428. <ChartZoom
  429. period={period}
  430. start={start}
  431. end={end}
  432. utc={utc}
  433. showSlider={showSlider}
  434. chartZoomOptions={chartZoomOptions}
  435. >
  436. {zoomRenderProps => {
  437. if (errorMessage) {
  438. return (
  439. <StyledErrorPanel>
  440. <IconWarning color="gray500" size="lg" />
  441. </StyledErrorPanel>
  442. );
  443. }
  444. const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
  445. const shouldColorOther = timeseriesResults?.some(({seriesName}) =>
  446. seriesName?.match(otherRegex)
  447. );
  448. const colors = timeseriesResults
  449. ? theme.charts.getColorPalette(
  450. timeseriesResults.length - (shouldColorOther ? 3 : 2)
  451. )
  452. : [];
  453. // TODO(wmak): Need to change this when updating dashboards to support variable topEvents
  454. if (shouldColorOther) {
  455. colors[colors.length] = theme.chartOther;
  456. }
  457. // Create a list of series based on the order of the fields,
  458. const series = timeseriesResults
  459. ? timeseriesResults
  460. .map((values, i: number) => {
  461. let seriesName = '';
  462. if (values.seriesName !== undefined) {
  463. seriesName = isEquation(values.seriesName)
  464. ? getEquation(values.seriesName)
  465. : values.seriesName;
  466. }
  467. return {
  468. ...values,
  469. seriesName,
  470. color: colors[i],
  471. };
  472. })
  473. .filter(Boolean) // NOTE: `timeseriesResults` is a sparse array! We have to filter out the empty slots after the colors are assigned, since the colours are assigned based on sparse array index
  474. : [];
  475. const seriesStart = series[0]?.data[0]?.name;
  476. const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
  477. const forwardedRef = this.props.chartGroup ? this.handleRef : undefined;
  478. return organization.features.includes('dashboards-releases-on-charts') &&
  479. widgetLegendState.widgetRequiresLegendUnselection(widget) ? (
  480. <ReleaseSeries
  481. end={end}
  482. start={start}
  483. period={period}
  484. environments={environments}
  485. projects={projects}
  486. memoized
  487. >
  488. {({releaseSeries}) => {
  489. // make series name into seriesName:widgetId form for individual widget legend control
  490. // NOTE: e-charts legends control all charts that have the same series name so attaching
  491. // widget id will differentiate the charts allowing them to be controlled individually
  492. const modifiedReleaseSeriesResults =
  493. WidgetLegendNameEncoderDecoder.modifyTimeseriesNames(
  494. widget,
  495. releaseSeries
  496. );
  497. return (
  498. <TransitionChart loading={loading} reloading={loading}>
  499. <LoadingScreen loading={loading} />
  500. <ChartWrapper
  501. autoHeightResize={shouldResize ?? true}
  502. noPadding={noPadding}
  503. >
  504. {getDynamicText({
  505. value: this.chartComponent({
  506. ...zoomRenderProps,
  507. ...chartOptions,
  508. // Override default datazoom behaviour for updating Global Selection Header
  509. ...(onZoom
  510. ? {
  511. onDataZoom: (evt, chartProps) =>
  512. // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
  513. // callback with percentage instead of datetime values. Passing seriesStart
  514. // and seriesEnd allows calculating datetime values with percentage.
  515. onZoom({...evt, seriesStart, seriesEnd}, chartProps),
  516. }
  517. : {}),
  518. legend,
  519. series: [...series, ...(modifiedReleaseSeriesResults ?? [])],
  520. onLegendSelectChanged,
  521. forwardedRef,
  522. }),
  523. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  524. })}
  525. </ChartWrapper>
  526. </TransitionChart>
  527. );
  528. }}
  529. </ReleaseSeries>
  530. ) : (
  531. <TransitionChart loading={loading} reloading={loading}>
  532. <LoadingScreen loading={loading} />
  533. <ChartWrapper autoHeightResize={shouldResize ?? true} noPadding={noPadding}>
  534. {getDynamicText({
  535. value: this.chartComponent({
  536. ...zoomRenderProps,
  537. ...chartOptions,
  538. // Override default datazoom behaviour for updating Global Selection Header
  539. ...(onZoom
  540. ? {
  541. onDataZoom: (evt, chartProps) =>
  542. // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
  543. // callback with percentage instead of datetime values. Passing seriesStart
  544. // and seriesEnd allows calculating datetime values with percentage.
  545. onZoom({...evt, seriesStart, seriesEnd}, chartProps),
  546. }
  547. : {}),
  548. legend,
  549. series,
  550. onLegendSelectChanged,
  551. forwardedRef,
  552. }),
  553. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  554. })}
  555. </ChartWrapper>
  556. </TransitionChart>
  557. );
  558. }}
  559. </ChartZoom>
  560. );
  561. }
  562. }
  563. export default withTheme(WidgetCardChart);
  564. const StyledTransparentLoadingMask = styled(props => (
  565. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  566. ))`
  567. display: flex;
  568. justify-content: center;
  569. align-items: center;
  570. `;
  571. function LoadingScreen({loading}: {loading: boolean}) {
  572. if (!loading) {
  573. return null;
  574. }
  575. return (
  576. <StyledTransparentLoadingMask visible={loading}>
  577. <LoadingIndicator mini />
  578. </StyledTransparentLoadingMask>
  579. );
  580. }
  581. const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
  582. <Placeholder height="200px" className={className} />
  583. ))`
  584. background-color: ${p => p.theme.surface300};
  585. `;
  586. const BigNumberResizeWrapper = styled('div')`
  587. flex-grow: 1;
  588. overflow: hidden;
  589. position: relative;
  590. `;
  591. const BigNumber = styled('div')`
  592. line-height: 1;
  593. display: inline-flex;
  594. flex: 1;
  595. width: 100%;
  596. min-height: 0;
  597. font-size: 32px;
  598. color: ${p => p.theme.headingColor};
  599. padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
  600. * {
  601. text-align: left !important;
  602. }
  603. `;
  604. const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
  605. ${p => p.autoHeightResize && 'height: 100%;'}
  606. width: 100%;
  607. padding: ${p => (p.noPadding ? `0` : `0 ${space(2)} ${space(2)}`)};
  608. `;
  609. const TableWrapper = styled('div')`
  610. margin-top: ${space(1.5)};
  611. min-height: 0;
  612. border-bottom-left-radius: ${p => p.theme.borderRadius};
  613. border-bottom-right-radius: ${p => p.theme.borderRadius};
  614. `;
  615. const StyledSimpleTableChart = styled(SimpleTableChart)`
  616. overflow: auto;
  617. height: 100%;
  618. `;
  619. const StyledErrorPanel = styled(ErrorPanel)`
  620. padding: ${space(2)};
  621. `;