Skip to content

Commit c5579d0

Browse files
gggritsoandrewshie-sentry
authored andcommitted
feat(dashboards): Basic BarChartWidget (#83427)
A barebones `BarChartWidget`, just enough to start working with it in an Explore context. Much more later, this just sets up some basic files.
1 parent e0fd3b1 commit c5579d0

9 files changed

+669
-2
lines changed

static/app/views/dashboards/widgets/areaChartWidget/areaChartWidgetSeries.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import LineSeries from 'sentry/components/charts/series/lineSeries';
22

33
import type {TimeseriesData} from '../common/types';
44

5+
/**
6+
*
7+
* @param timeserie
8+
* @param complete Whether this series is fully ingested and processed data, or it's still behind the ingestion delay
9+
*/
510
export function AreaChartWidgetSeries(timeserie: TimeseriesData, complete?: boolean) {
611
return complete
712
? LineSeries({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {BarChartWidget} from './barChartWidget';
4+
import sampleLatencyTimeSeries from './sampleLatencyTimeSeries.json';
5+
import sampleSpanDurationTimeSeries from './sampleSpanDurationTimeSeries.json';
6+
7+
describe('BarChartWidget', () => {
8+
describe('Layout', () => {
9+
it('Renders', () => {
10+
render(
11+
<BarChartWidget
12+
title="eps()"
13+
description="Number of events per second"
14+
timeseries={[sampleLatencyTimeSeries, sampleSpanDurationTimeSeries]}
15+
/>
16+
);
17+
});
18+
});
19+
20+
describe('Visualization', () => {
21+
it('Explains missing data', () => {
22+
render(<BarChartWidget />);
23+
24+
expect(screen.getByText('No Data')).toBeInTheDocument();
25+
});
26+
});
27+
28+
describe('State', () => {
29+
it('Shows a loading placeholder', () => {
30+
render(<BarChartWidget isLoading />);
31+
32+
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
33+
});
34+
35+
it('Loading state takes precedence over error state', () => {
36+
render(
37+
<BarChartWidget isLoading error={new Error('Parsing error of old value')} />
38+
);
39+
40+
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
41+
});
42+
43+
it('Shows an error message', () => {
44+
render(<BarChartWidget error={new Error('Uh oh')} />);
45+
46+
expect(screen.getByText('Error: Uh oh')).toBeInTheDocument();
47+
});
48+
49+
it('Shows a retry button', async () => {
50+
const onRetry = jest.fn();
51+
52+
render(<BarChartWidget error={new Error('Oh no!')} onRetry={onRetry} />);
53+
54+
await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
55+
expect(onRetry).toHaveBeenCalledTimes(1);
56+
});
57+
58+
it('Hides other actions if there is an error and a retry handler', () => {
59+
const onRetry = jest.fn();
60+
61+
render(
62+
<BarChartWidget
63+
error={new Error('Oh no!')}
64+
onRetry={onRetry}
65+
actions={[
66+
{
67+
key: 'Open in Discover',
68+
to: '/discover',
69+
},
70+
]}
71+
/>
72+
);
73+
74+
expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
75+
expect(
76+
screen.queryByRole('link', {name: 'Open in Discover'})
77+
).not.toBeInTheDocument();
78+
});
79+
});
80+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {Fragment} from 'react';
2+
import styled from '@emotion/styled';
3+
import moment from 'moment-timezone';
4+
5+
import JSXNode from 'sentry/components/stories/jsxNode';
6+
import SizingWindow from 'sentry/components/stories/sizingWindow';
7+
import storyBook from 'sentry/stories/storyBook';
8+
import type {DateString} from 'sentry/types/core';
9+
import usePageFilters from 'sentry/utils/usePageFilters';
10+
11+
import type {TimeseriesData} from '../common/types';
12+
13+
import {BarChartWidget} from './barChartWidget';
14+
import sampleLatencyTimeSeries from './sampleLatencyTimeSeries.json';
15+
import sampleSpanDurationTimeSeries from './sampleSpanDurationTimeSeries.json';
16+
17+
export default storyBook(BarChartWidget, story => {
18+
story('Getting Started', () => {
19+
return (
20+
<Fragment>
21+
<p>
22+
<JSXNode name="BarChartWidget" /> is a Dashboard Widget Component. It displays a
23+
timeseries chart with multiple timeseries, and the timeseries are stacked and
24+
discontinuous. In all other ways, it behaves like{' '}
25+
<JSXNode name="AreaChartWidget" />
26+
</p>
27+
<p>
28+
<em>NOTE:</em> Prefer <JSXNode name="LineChartWidget" /> and{' '}
29+
<JSXNode name="AreaChartWidget" /> for timeseries visualizations! This should be
30+
used for discontinuous categorized data.
31+
</p>
32+
</Fragment>
33+
);
34+
});
35+
36+
story('Visualization', () => {
37+
const {selection} = usePageFilters();
38+
const {datetime} = selection;
39+
const {start, end} = datetime;
40+
41+
const latencyTimeSeries = toTimeSeriesSelection(
42+
sampleLatencyTimeSeries as unknown as TimeseriesData,
43+
start,
44+
end
45+
);
46+
47+
const spanDurationTimeSeries = toTimeSeriesSelection(
48+
sampleSpanDurationTimeSeries as unknown as TimeseriesData,
49+
start,
50+
end
51+
);
52+
53+
return (
54+
<Fragment>
55+
<p>
56+
The visualization of <JSXNode name="BarChartWidget" /> is a stacked bar chart.
57+
It has some bells and whistles including automatic axes labels, and a hover
58+
tooltip. Like other widgets, it automatically fills the parent element.
59+
</p>
60+
<SmallSizingWindow>
61+
<BarChartWidget
62+
title="Duration Breakdown"
63+
description="Explains what proportion of total duration is taken up by latency vs. span duration"
64+
timeseries={[latencyTimeSeries, spanDurationTimeSeries]}
65+
/>
66+
</SmallSizingWindow>
67+
</Fragment>
68+
);
69+
});
70+
});
71+
72+
const SmallSizingWindow = styled(SizingWindow)`
73+
width: 50%;
74+
height: 300px;
75+
`;
76+
77+
function toTimeSeriesSelection(
78+
timeSeries: TimeseriesData,
79+
start: DateString | null,
80+
end: DateString | null
81+
): TimeseriesData {
82+
return {
83+
...timeSeries,
84+
data: timeSeries.data.filter(datum => {
85+
if (start && moment(datum.timestamp).isBefore(moment.utc(start))) {
86+
return false;
87+
}
88+
89+
if (end && moment(datum.timestamp).isAfter(moment.utc(end))) {
90+
return false;
91+
}
92+
93+
return true;
94+
}),
95+
};
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {
2+
TimeSeriesWidget,
3+
type TimeSeriesWidgetProps,
4+
} from '../timeSeriesWidget/timeSeriesWidget';
5+
6+
import {BarChartWidgetSeries} from './barChartWidgetSeries';
7+
8+
export interface BarChartWidgetProps
9+
extends Omit<TimeSeriesWidgetProps, 'SeriesConstructor'> {}
10+
11+
export function BarChartWidget(props: BarChartWidgetProps) {
12+
return <TimeSeriesWidget {...props} SeriesConstructor={BarChartWidgetSeries} />;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import BarSeries from 'sentry/components/charts/series/barSeries';
2+
3+
import type {TimeseriesData} from '../common/types';
4+
5+
/**
6+
*
7+
* @param timeserie
8+
* @param complete Whether this series is fully ingested and processed data, or it's still behind the ingestion delay
9+
*/
10+
export function BarChartWidgetSeries(timeserie: TimeseriesData, complete?: boolean) {
11+
return complete
12+
? BarSeries({
13+
name: timeserie.field,
14+
color: timeserie.color,
15+
stack: 'complete',
16+
animation: false,
17+
itemStyle: {
18+
color: timeserie.color,
19+
opacity: 1.0,
20+
},
21+
data: timeserie.data.map(datum => {
22+
return [datum.timestamp, datum.value];
23+
}),
24+
})
25+
: BarSeries({
26+
name: timeserie.field,
27+
color: timeserie.color,
28+
stack: 'incomplete',
29+
animation: false,
30+
data: timeserie.data.map(datum => {
31+
return [datum.timestamp, datum.value];
32+
}),
33+
itemStyle: {
34+
color: timeserie.color,
35+
opacity: 0.8,
36+
},
37+
silent: true,
38+
});
39+
}

0 commit comments

Comments
 (0)