Skip to content

Commit dfaa51b

Browse files
gggritsoandrewshie-sentry
authored andcommitted
feat(dashboards): Add AreaChartWidget (#81962)
Adds an `AreaChartWidget`. Just like `LineChartWidget` but doesn't support as many features for now! This creates a whole bunch of code duplication that I'll resolve over time. Closes #81836
1 parent 94176a0 commit dfaa51b

13 files changed

+1030
-4
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {AreaChartWidget} from './areaChartWidget';
4+
import sampleLatencyTimeSeries from './sampleLatencyTimeSeries.json';
5+
import sampleSpanDurationTimeSeries from './sampleSpanDurationTimeSeries.json';
6+
7+
describe('AreaChartWidget', () => {
8+
describe('Layout', () => {
9+
it('Renders', () => {
10+
render(
11+
<AreaChartWidget
12+
title="eps()"
13+
description="Number of events per second"
14+
timeseries={[sampleLatencyTimeSeries, sampleSpanDurationTimeSeries]}
15+
meta={{
16+
fields: {
17+
'eps()': 'rate',
18+
},
19+
units: {
20+
'eps()': '1/second',
21+
},
22+
}}
23+
/>
24+
);
25+
});
26+
});
27+
28+
describe('Visualization', () => {
29+
it('Explains missing data', () => {
30+
render(<AreaChartWidget />);
31+
32+
expect(screen.getByText('No Data')).toBeInTheDocument();
33+
});
34+
});
35+
36+
describe('State', () => {
37+
it('Shows a loading placeholder', () => {
38+
render(<AreaChartWidget isLoading />);
39+
40+
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
41+
});
42+
43+
it('Loading state takes precedence over error state', () => {
44+
render(
45+
<AreaChartWidget isLoading error={new Error('Parsing error of old value')} />
46+
);
47+
48+
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
49+
});
50+
51+
it('Shows an error message', () => {
52+
render(<AreaChartWidget error={new Error('Uh oh')} />);
53+
54+
expect(screen.getByText('Error: Uh oh')).toBeInTheDocument();
55+
});
56+
57+
it('Shows a retry button', async () => {
58+
const onRetry = jest.fn();
59+
60+
render(<AreaChartWidget error={new Error('Oh no!')} onRetry={onRetry} />);
61+
62+
await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
63+
expect(onRetry).toHaveBeenCalledTimes(1);
64+
});
65+
66+
it('Hides other actions if there is an error and a retry handler', () => {
67+
const onRetry = jest.fn();
68+
69+
render(
70+
<AreaChartWidget
71+
error={new Error('Oh no!')}
72+
onRetry={onRetry}
73+
actions={[
74+
{
75+
key: 'Open in Discover',
76+
to: '/discover',
77+
},
78+
]}
79+
/>
80+
);
81+
82+
expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
83+
expect(
84+
screen.queryByRole('link', {name: 'Open in Discover'})
85+
).not.toBeInTheDocument();
86+
});
87+
});
88+
});
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import {Fragment} from 'react';
2+
import {useTheme} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
import moment from 'moment-timezone';
5+
6+
import JSXNode from 'sentry/components/stories/jsxNode';
7+
import SideBySide from 'sentry/components/stories/sideBySide';
8+
import SizingWindow from 'sentry/components/stories/sizingWindow';
9+
import storyBook from 'sentry/stories/storyBook';
10+
import type {DateString} from 'sentry/types/core';
11+
import usePageFilters from 'sentry/utils/usePageFilters';
12+
13+
import type {Release, TimeseriesData} from '../common/types';
14+
15+
import {AreaChartWidget} from './areaChartWidget';
16+
import sampleLatencyTimeSeries from './sampleLatencyTimeSeries.json';
17+
import sampleSpanDurationTimeSeries from './sampleSpanDurationTimeSeries.json';
18+
19+
export default storyBook(AreaChartWidget, story => {
20+
story('Getting Started', () => {
21+
return (
22+
<Fragment>
23+
<p>
24+
<JSXNode name="AreaChartWidget" /> is a Dashboard Widget Component. It displays
25+
a timeseries chart with multiple timeseries, and the timeseries are stacked.
26+
Each timeseries is shown using a solid block of color. This chart is used to
27+
visualize multiple timeseries that represent parts of something. For example, a
28+
chart that shows time spent in the app broken down by component. In all other
29+
ways, it behaves like <JSXNode name="LineChartWidget" />, though it doesn't
30+
support features like "Previous Period Data".
31+
</p>
32+
<p>
33+
<em>NOTE:</em> This chart is not appropriate for showing a single timeseries!
34+
You should use <JSXNode name="LineChartWidget" /> instead.
35+
</p>
36+
</Fragment>
37+
);
38+
});
39+
40+
story('Visualization', () => {
41+
const {selection} = usePageFilters();
42+
const {datetime} = selection;
43+
const {start, end} = datetime;
44+
45+
const latencyTimeSeries = toTimeSeriesSelection(
46+
sampleLatencyTimeSeries as unknown as TimeseriesData,
47+
start,
48+
end
49+
);
50+
51+
const spanDurationTimeSeries = toTimeSeriesSelection(
52+
sampleSpanDurationTimeSeries as unknown as TimeseriesData,
53+
start,
54+
end
55+
);
56+
57+
return (
58+
<Fragment>
59+
<p>
60+
The visualization of <JSXNode name="AreaChartWidget" /> a stacked area chart. It
61+
has some bells and whistles including automatic axes labels, and a hover
62+
tooltip. Like other widgets, it automatically fills the parent element.
63+
</p>
64+
<SmallSizingWindow>
65+
<AreaChartWidget
66+
title="Duration Breakdown"
67+
description="Explains what proportion of total duration is taken up by latency vs. span duration"
68+
timeseries={[latencyTimeSeries, spanDurationTimeSeries]}
69+
meta={{
70+
fields: {
71+
'avg(latency)': 'duration',
72+
'avg(span.duration)': 'duration',
73+
},
74+
units: {
75+
'avg(latency)': 'millisecond',
76+
'avg(span.duration)': 'millisecond',
77+
},
78+
}}
79+
/>
80+
</SmallSizingWindow>
81+
</Fragment>
82+
);
83+
});
84+
85+
story('State', () => {
86+
return (
87+
<Fragment>
88+
<p>
89+
<JSXNode name="AreaChartWidget" /> supports the usual loading and error states.
90+
The loading state shows a spinner. The error state shows a message, and an
91+
optional "Retry" button.
92+
</p>
93+
94+
<SideBySide>
95+
<SmallWidget>
96+
<AreaChartWidget title="Loading Count" isLoading />
97+
</SmallWidget>
98+
<SmallWidget>
99+
<AreaChartWidget title="Missing Count" />
100+
</SmallWidget>
101+
<SmallWidget>
102+
<AreaChartWidget
103+
title="Count Error"
104+
error={new Error('Something went wrong!')}
105+
/>
106+
</SmallWidget>
107+
<SmallWidget>
108+
<AreaChartWidget
109+
title="Data Error"
110+
error={new Error('Something went wrong!')}
111+
onRetry={() => {}}
112+
/>
113+
</SmallWidget>
114+
</SideBySide>
115+
</Fragment>
116+
);
117+
});
118+
119+
story('Colors', () => {
120+
const theme = useTheme();
121+
122+
return (
123+
<Fragment>
124+
<p>
125+
You can control the color of each timeseries by setting the <code>color</code>{' '}
126+
attribute to a string that contains a valid hex color code.
127+
</p>
128+
129+
<MediumWidget>
130+
<AreaChartWidget
131+
title="error_rate()"
132+
description="Rate of Errors"
133+
timeseries={[
134+
{...sampleLatencyTimeSeries, color: theme.error},
135+
136+
{...sampleSpanDurationTimeSeries, color: theme.warning},
137+
]}
138+
meta={{
139+
fields: {
140+
'avg(latency)': 'duration',
141+
'avg(span.duration)': 'duration',
142+
},
143+
units: {
144+
'avg(latency)': 'millisecond',
145+
'avg(span.duration)': 'millisecond',
146+
},
147+
}}
148+
/>
149+
</MediumWidget>
150+
</Fragment>
151+
);
152+
});
153+
154+
story('Releases', () => {
155+
const releases = [
156+
{
157+
version: 'ui@0.1.2',
158+
timestamp: sampleLatencyTimeSeries.data.at(2)?.timestamp,
159+
},
160+
{
161+
version: 'ui@0.1.3',
162+
timestamp: sampleLatencyTimeSeries.data.at(20)?.timestamp,
163+
},
164+
].filter(hasTimestamp);
165+
166+
return (
167+
<Fragment>
168+
<p>
169+
<JSXNode name="AreaChartWidget" /> supports the <code>releases</code> prop. If
170+
passed in, the widget will plot every release as a vertical line that overlays
171+
the chart data. Clicking on a release line will open the release details page.
172+
</p>
173+
174+
<MediumWidget>
175+
<AreaChartWidget
176+
title="error_rate()"
177+
timeseries={[sampleLatencyTimeSeries, sampleSpanDurationTimeSeries]}
178+
meta={{
179+
fields: {
180+
'avg(latency)': 'duration',
181+
'avg(span.duration)': 'duration',
182+
},
183+
units: {
184+
'avg(latency)': 'millisecond',
185+
'avg(span.duration)': 'millisecond',
186+
},
187+
}}
188+
releases={releases}
189+
/>
190+
</MediumWidget>
191+
</Fragment>
192+
);
193+
});
194+
});
195+
196+
const MediumWidget = styled('div')`
197+
width: 420px;
198+
height: 250px;
199+
`;
200+
201+
const SmallWidget = styled('div')`
202+
width: 360px;
203+
height: 160px;
204+
`;
205+
206+
const SmallSizingWindow = styled(SizingWindow)`
207+
width: 50%;
208+
height: 300px;
209+
`;
210+
211+
function toTimeSeriesSelection(
212+
timeSeries: TimeseriesData,
213+
start: DateString | null,
214+
end: DateString | null
215+
): TimeseriesData {
216+
return {
217+
...timeSeries,
218+
data: timeSeries.data.filter(datum => {
219+
if (start && moment(datum.timestamp).isBefore(moment.utc(start))) {
220+
return false;
221+
}
222+
223+
if (end && moment(datum.timestamp).isAfter(moment.utc(end))) {
224+
return false;
225+
}
226+
227+
return true;
228+
}),
229+
};
230+
}
231+
232+
function hasTimestamp(release: Partial<Release>): release is Release {
233+
return Boolean(release?.timestamp);
234+
}

0 commit comments

Comments
 (0)