diff --git a/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/AreaChart.test.js b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/AreaChart.test.js new file mode 100644 index 00000000000..88215a4a967 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/AreaChart.test.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AreaChart from './'; +import { areaChartData } from './AreaChart.fixtures'; + +jest.unmock('./'); + +describe('AreaChart', () => { + it('renders chart with valid data', () => { + const { container } = render( + + ); + + // Check for chart element by aria attributes (accessible name is the yAxisLabel) + expect(screen.getByRole('img', { name: areaChartData.yAxisLabel })).toBeInTheDocument(); + + // Verify chart container is present + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders empty state when no data provided', () => { + render(); + + // Should show the no data message + expect(screen.getByText('No data available')).toBeInTheDocument(); + + // Should not render chart + expect(screen.queryByRole('img', { name: /area chart/i })).not.toBeInTheDocument(); + }); + + it('renders empty state when empty data array provided', () => { + render(); + + // Should show the no data message + expect(screen.getByText('No data available')).toBeInTheDocument(); + + // Should not render chart + expect(screen.queryByRole('img', { name: /area chart/i })).not.toBeInTheDocument(); + }); + + it('displays custom noDataMsg', () => { + const customMsg = 'Custom no data message'; + render(); + + // Should display the custom message + expect(screen.getByText(customMsg)).toBeInTheDocument(); + }); + + it('applies custom size to chart', () => { + const customSize = { width: 500, height: 300 }; + const { container } = render( + + ); + + // Check the chart wrapper div has correct styles + const chartWrapper = container.querySelector('div[style*="height"]'); + expect(chartWrapper).toHaveStyle({ height: '300px', width: '500px' }); + }); + + it('renders with onclick callback', () => { + const onClickMock = jest.fn(); + const { container } = render( + + ); + + // Chart should render when onclick is provided + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders all data series', () => { + const { container } = render( + + ); + + // Verify the chart renders (contains SVG) - accessible name is the yAxisLabel + expect(screen.getByRole('img', { name: areaChartData.yAxisLabel })).toBeInTheDocument(); + + // The chart should be rendered with the data + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('handles missing xAxisDataLabel gracefully', () => { + const dataWithoutTime = [ + ['nottime', 1614449768, 1614451500], + ['CentOS 7.9', 3, 5], + ]; + + render( + + ); + + // Should render empty state when time column is not found + expect(screen.getByText('No data available')).toBeInTheDocument(); + }); +}); + diff --git a/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js index 1dd6aa7af68..b00f9d0ce96 100644 --- a/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js +++ b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js @@ -1,7 +1,15 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { AreaChart as PfAreaChart } from 'patternfly-react'; -import { getAreaChartConfig } from '../../../../../services/charts/AreaChartService'; +import { + Chart, + ChartArea, + ChartAxis, + ChartGroup, + ChartStack, + ChartThemeColor, + ChartVoronoiContainer, + ChartLegend, +} from '@patternfly/react-charts'; import { noop } from '../../../../common/helpers'; import { translate as __ } from '../../../../common/I18n'; import MessageBox from '../../MessageBox'; @@ -11,35 +19,187 @@ const AreaChart = ({ onclick, noDataMsg, config, - unloadData, + unloadData, // eslint-disable-line no-unused-vars xAxisDataLabel, yAxisLabel, size, }) => { - const chartConfig = getAreaChartConfig({ - data, - config, - onclick, - yAxisLabel, - xAxisDataLabel, - size, - }); - - if (chartConfig.data.columns.length) { - return ; + const chartData = useMemo(() => { + if (!data || data.length === 0) { + return null; + } + + // Extract timestamps from first array + const timeColumn = data.find(col => col[0] === xAxisDataLabel); + if (!timeColumn) { + return null; + } + + const timestamps = timeColumn.slice(1); + const dates = timestamps.map(epochSecs => new Date(epochSecs * 1000)); + + // Process other data columns + const series = data + .filter(col => col[0] !== xAxisDataLabel) + .map(col => { + const name = col[0]; + const values = col.slice(1); + return { + name, + data: values.map((value, index) => ({ + x: dates[index], + y: value, + name, + })), + }; + }); + + return series.length > 0 ? series : null; + }, [data, xAxisDataLabel]); + + const handleClick = useMemo( + () => (event, datum) => { + if (onclick && datum && datum.name) { + onclick({ id: datum.name }); + } + }, + [onclick] + ); + + if (!chartData) { + return ; } - return ; + + const chartHeight = size?.height || 250; + const chartWidth = size?.width || undefined; + const padding = { bottom: 75, left: 75, right: 50, top: 50 }; + + const legendData = chartData.map(series => ({ name: series.name })); + + return ( +
+ { + const value = datum.y0 !== undefined ? datum.y - datum.y0 : datum.y; + const seriesName = datum.childName || datum.name; + + // Show timestamp for the first series + const firstSeriesName = chartData[0]?.name; + const isFirstSeries = seriesName === firstSeriesName; + + let label = ''; + + // Add timestamp as first line for first series only + if (isFirstSeries && datum.x) { + const date = datum.x instanceof Date ? datum.x : new Date(datum.x); + const dateStr = date.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' }); + const timeStr = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true }); + label = `${dateStr}, ${timeStr}\n`; + } + + // Add series value if non-zero + if (value !== 0) { + const valueLine = seriesName ? `${seriesName}: ${Math.round(value)}` : `${Math.round(value)}`; + label += valueLine; + } else if (isFirstSeries) { + // Show first series even if zero to ensure timestamp displays + const valueLine = seriesName ? `${seriesName}: ${Math.round(value)}` : `${Math.round(value)}`; + label += valueLine; + } + + return label || null; + }} + constrainToVisibleArea + /> + } + height={chartHeight} + width={chartWidth} + padding={padding} + themeColor={ChartThemeColor.orange} + legendData={legendData} + legendOrientation="horizontal" + legendPosition="bottom" + legendComponent={} + events={[ + { + target: 'data', + eventHandlers: { + onClick: () => [ + { + target: 'data', + mutation: props => { + handleClick(null, props.datum); + return null; + }, + }, + ], + }, + }, + ]} + > + { + if (config === 'timeseries' && t instanceof Date) { + return t.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + } + return t; + }} + fixLabelOverlap + /> + Math.round(t)} + /> + {config === 'timeseries' ? ( + + {chartData.map(series => ( + + ))} + + ) : ( + + {chartData.map(series => ( + + ))} + + )} + +
+ ); }; AreaChart.propTypes = { + /** Array of data arrays. First element of each array is the label, rest are values. + * For timeseries, one array should have xAxisDataLabel as first element and timestamps as subsequent values. + * Example: [['time', 1614449768, 1614451500], ['CentOS 7.9', 3, 5], ['CentOS 7.6', 2, 1]] + */ data: PropTypes.arrayOf(PropTypes.array), + /** Function called when clicking on data points. Receives object with 'id' property containing the series name. */ onclick: PropTypes.func, + /** Message to display when no data is available */ noDataMsg: PropTypes.string, + /** Chart configuration type. Only 'timeseries' is currently supported. */ config: PropTypes.oneOf(['timeseries']), + /** Legacy prop for compatibility - no longer used in PatternFly 5 */ unloadData: PropTypes.bool, + /** Label for the x-axis data column (must match first element of one data array) */ xAxisDataLabel: PropTypes.string, + /** Label for the y-axis */ yAxisLabel: PropTypes.string, - size: PropTypes.object, + /** Optional size object with width and/or height properties */ + size: PropTypes.shape({ + height: PropTypes.number, + width: PropTypes.number, + }), }; AreaChart.defaultProps = {