Skip to content

Commit a5bf69a

Browse files
committed
Fixes #38898 - Update AreaChart to PatterFly5
1 parent a48be3b commit a5bf69a

File tree

2 files changed

+283
-17
lines changed

2 files changed

+283
-17
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import AreaChart from './';
5+
import { areaChartData } from './AreaChart.fixtures';
6+
7+
jest.unmock('./');
8+
9+
describe('AreaChart', () => {
10+
it('renders chart with valid data', () => {
11+
const { container } = render(
12+
<AreaChart
13+
data={areaChartData.data}
14+
yAxisLabel={areaChartData.yAxisLabel}
15+
/>
16+
);
17+
18+
// Check for chart element by aria attributes (accessible name is the yAxisLabel)
19+
expect(screen.getByRole('img', { name: areaChartData.yAxisLabel })).toBeInTheDocument();
20+
21+
// Verify chart container is present
22+
expect(container.querySelector('svg')).toBeInTheDocument();
23+
});
24+
25+
it('renders empty state when no data provided', () => {
26+
render(<AreaChart data={null} />);
27+
28+
// Should show the no data message
29+
expect(screen.getByText('No data available')).toBeInTheDocument();
30+
31+
// Should not render chart
32+
expect(screen.queryByRole('img', { name: /area chart/i })).not.toBeInTheDocument();
33+
});
34+
35+
it('renders empty state when empty data array provided', () => {
36+
render(<AreaChart data={[]} />);
37+
38+
// Should show the no data message
39+
expect(screen.getByText('No data available')).toBeInTheDocument();
40+
41+
// Should not render chart
42+
expect(screen.queryByRole('img', { name: /area chart/i })).not.toBeInTheDocument();
43+
});
44+
45+
it('displays custom noDataMsg', () => {
46+
const customMsg = 'Custom no data message';
47+
render(<AreaChart data={null} noDataMsg={customMsg} />);
48+
49+
// Should display the custom message
50+
expect(screen.getByText(customMsg)).toBeInTheDocument();
51+
});
52+
53+
it('applies custom size to chart', () => {
54+
const customSize = { width: 500, height: 300 };
55+
const { container } = render(
56+
<AreaChart data={areaChartData.data} size={customSize} />
57+
);
58+
59+
// Check the chart wrapper div has correct styles
60+
const chartWrapper = container.querySelector('div[style*="height"]');
61+
expect(chartWrapper).toHaveStyle({ height: '300px', width: '500px' });
62+
});
63+
64+
it('renders with onclick callback', () => {
65+
const onClickMock = jest.fn();
66+
const { container } = render(
67+
<AreaChart data={areaChartData.data} onclick={onClickMock} />
68+
);
69+
70+
// Chart should render when onclick is provided
71+
expect(container.querySelector('svg')).toBeInTheDocument();
72+
});
73+
74+
it('renders all data series', () => {
75+
const { container } = render(
76+
<AreaChart
77+
data={areaChartData.data}
78+
yAxisLabel={areaChartData.yAxisLabel}
79+
/>
80+
);
81+
82+
// Verify the chart renders (contains SVG) - accessible name is the yAxisLabel
83+
expect(screen.getByRole('img', { name: areaChartData.yAxisLabel })).toBeInTheDocument();
84+
85+
// The chart should be rendered with the data
86+
expect(container.querySelector('svg')).toBeInTheDocument();
87+
});
88+
89+
it('handles missing xAxisDataLabel gracefully', () => {
90+
const dataWithoutTime = [
91+
['nottime', 1614449768, 1614451500],
92+
['CentOS 7.9', 3, 5],
93+
];
94+
95+
render(
96+
<AreaChart
97+
data={dataWithoutTime}
98+
xAxisDataLabel="time"
99+
/>
100+
);
101+
102+
// Should render empty state when time column is not found
103+
expect(screen.getByText('No data available')).toBeInTheDocument();
104+
});
105+
});
106+

webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js

Lines changed: 177 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22
import PropTypes from 'prop-types';
3-
import { AreaChart as PfAreaChart } from 'patternfly-react';
4-
import { getAreaChartConfig } from '../../../../../services/charts/AreaChartService';
3+
import {
4+
Chart,
5+
ChartArea,
6+
ChartAxis,
7+
ChartGroup,
8+
ChartStack,
9+
ChartThemeColor,
10+
ChartVoronoiContainer,
11+
ChartLegend,
12+
} from '@patternfly/react-charts';
513
import { noop } from '../../../../common/helpers';
614
import { translate as __ } from '../../../../common/I18n';
715
import MessageBox from '../../MessageBox';
@@ -11,35 +19,187 @@ const AreaChart = ({
1119
onclick,
1220
noDataMsg,
1321
config,
14-
unloadData,
22+
unloadData, // eslint-disable-line no-unused-vars
1523
xAxisDataLabel,
1624
yAxisLabel,
1725
size,
1826
}) => {
19-
const chartConfig = getAreaChartConfig({
20-
data,
21-
config,
22-
onclick,
23-
yAxisLabel,
24-
xAxisDataLabel,
25-
size,
26-
});
27-
28-
if (chartConfig.data.columns.length) {
29-
return <PfAreaChart {...chartConfig} unloadBeforeLoad={unloadData} />;
27+
const chartData = useMemo(() => {
28+
if (!data || data.length === 0) {
29+
return null;
30+
}
31+
32+
// Extract timestamps from first array
33+
const timeColumn = data.find(col => col[0] === xAxisDataLabel);
34+
if (!timeColumn) {
35+
return null;
36+
}
37+
38+
const timestamps = timeColumn.slice(1);
39+
const dates = timestamps.map(epochSecs => new Date(epochSecs * 1000));
40+
41+
// Process other data columns
42+
const series = data
43+
.filter(col => col[0] !== xAxisDataLabel)
44+
.map(col => {
45+
const name = col[0];
46+
const values = col.slice(1);
47+
return {
48+
name,
49+
data: values.map((value, index) => ({
50+
x: dates[index],
51+
y: value,
52+
name,
53+
})),
54+
};
55+
});
56+
57+
return series.length > 0 ? series : null;
58+
}, [data, xAxisDataLabel]);
59+
60+
const handleClick = useMemo(
61+
() => (event, datum) => {
62+
if (onclick && datum && datum.name) {
63+
onclick({ id: datum.name });
64+
}
65+
},
66+
[onclick]
67+
);
68+
69+
if (!chartData) {
70+
return <MessageBox msg={noDataMsg} icontype="info" />;
3071
}
31-
return <MessageBox msg={noDataMsg} icontype="info" />;
72+
73+
const chartHeight = size?.height || 250;
74+
const chartWidth = size?.width || undefined;
75+
const padding = { bottom: 75, left: 75, right: 50, top: 50 };
76+
77+
const legendData = chartData.map(series => ({ name: series.name }));
78+
79+
return (
80+
<div style={{ height: chartHeight, width: chartWidth || '100%' }}>
81+
<Chart
82+
ariaDesc="Area chart"
83+
ariaTitle={yAxisLabel || 'Area chart'}
84+
containerComponent={
85+
<ChartVoronoiContainer
86+
labels={({ datum }) => {
87+
const value = datum.y0 !== undefined ? datum.y - datum.y0 : datum.y;
88+
const seriesName = datum.childName || datum.name;
89+
90+
// Show timestamp for the first series
91+
const firstSeriesName = chartData[0]?.name;
92+
const isFirstSeries = seriesName === firstSeriesName;
93+
94+
let label = '';
95+
96+
// Add timestamp as first line for first series only
97+
if (isFirstSeries && datum.x) {
98+
const date = datum.x instanceof Date ? datum.x : new Date(datum.x);
99+
const dateStr = date.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' });
100+
const timeStr = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
101+
label = `${dateStr}, ${timeStr}\n`;
102+
}
103+
104+
// Add series value if non-zero
105+
if (value !== 0) {
106+
const valueLine = seriesName ? `${seriesName}: ${Math.round(value)}` : `${Math.round(value)}`;
107+
label += valueLine;
108+
} else if (isFirstSeries) {
109+
// Show first series even if zero to ensure timestamp displays
110+
const valueLine = seriesName ? `${seriesName}: ${Math.round(value)}` : `${Math.round(value)}`;
111+
label += valueLine;
112+
}
113+
114+
return label || null;
115+
}}
116+
constrainToVisibleArea
117+
/>
118+
}
119+
height={chartHeight}
120+
width={chartWidth}
121+
padding={padding}
122+
themeColor={ChartThemeColor.orange}
123+
legendData={legendData}
124+
legendOrientation="horizontal"
125+
legendPosition="bottom"
126+
legendComponent={<ChartLegend />}
127+
events={[
128+
{
129+
target: 'data',
130+
eventHandlers: {
131+
onClick: () => [
132+
{
133+
target: 'data',
134+
mutation: props => {
135+
handleClick(null, props.datum);
136+
return null;
137+
},
138+
},
139+
],
140+
},
141+
},
142+
]}
143+
>
144+
<ChartAxis
145+
tickFormat={t => {
146+
if (config === 'timeseries' && t instanceof Date) {
147+
return t.toLocaleDateString(undefined, {
148+
month: 'short',
149+
day: 'numeric',
150+
});
151+
}
152+
return t;
153+
}}
154+
fixLabelOverlap
155+
/>
156+
<ChartAxis
157+
dependentAxis
158+
showGrid
159+
label={yAxisLabel}
160+
tickFormat={t => Math.round(t)}
161+
/>
162+
{config === 'timeseries' ? (
163+
<ChartStack>
164+
{chartData.map(series => (
165+
<ChartArea key={series.name} data={series.data} name={series.name} />
166+
))}
167+
</ChartStack>
168+
) : (
169+
<ChartGroup>
170+
{chartData.map(series => (
171+
<ChartArea key={series.name} data={series.data} name={series.name} />
172+
))}
173+
</ChartGroup>
174+
)}
175+
</Chart>
176+
</div>
177+
);
32178
};
33179

34180
AreaChart.propTypes = {
181+
/** Array of data arrays. First element of each array is the label, rest are values.
182+
* For timeseries, one array should have xAxisDataLabel as first element and timestamps as subsequent values.
183+
* Example: [['time', 1614449768, 1614451500], ['CentOS 7.9', 3, 5], ['CentOS 7.6', 2, 1]]
184+
*/
35185
data: PropTypes.arrayOf(PropTypes.array),
186+
/** Function called when clicking on data points. Receives object with 'id' property containing the series name. */
36187
onclick: PropTypes.func,
188+
/** Message to display when no data is available */
37189
noDataMsg: PropTypes.string,
190+
/** Chart configuration type. Only 'timeseries' is currently supported. */
38191
config: PropTypes.oneOf(['timeseries']),
192+
/** Legacy prop for compatibility - no longer used in PatternFly 5 */
39193
unloadData: PropTypes.bool,
194+
/** Label for the x-axis data column (must match first element of one data array) */
40195
xAxisDataLabel: PropTypes.string,
196+
/** Label for the y-axis */
41197
yAxisLabel: PropTypes.string,
42-
size: PropTypes.object,
198+
/** Optional size object with width and/or height properties */
199+
size: PropTypes.shape({
200+
height: PropTypes.number,
201+
width: PropTypes.number,
202+
}),
43203
};
44204

45205
AreaChart.defaultProps = {

0 commit comments

Comments
 (0)