Sparkline: Add warnings for invalid series, and add more test cases

This commit is contained in:
Paul Marbach
2025-11-21 15:04:24 -05:00
parent 7f100bf104
commit 65e740fe9c
4 changed files with 182 additions and 64 deletions
@@ -1,74 +1,127 @@
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { createTheme, FieldSparkline, FieldType } from '@grafana/data';
import { createTheme, Field, FieldSparkline, FieldType } from '@grafana/data';
import { Sparkline } from './Sparkline';
describe('Sparkline', () => {
it('should render without throwing an error', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [1, 2, 3, 4, 5],
type: FieldType.number,
config: {},
state: {
range: { min: 1, max: 5, delta: 1 },
describe('renders without error', () => {
const numField = (name: string, numVals: number): Field => ({
name,
values: Array.from({ length: numVals }, (_, i) => i + 1),
type: FieldType.number,
config: {},
state:
numVals > 0
? {
range: { min: 1, max: numVals, delta: numVals - 1 },
}
: {},
});
const startTime = 1679839200000;
const timeField = (name: string, numVals: number): Field => ({
name,
values: Array.from({ length: numVals }, (_, i) => startTime + (i + 1) * 1000),
type: FieldType.time,
config: {},
state:
numVals > 0
? {
range: { min: 1, max: numVals, delta: numVals - 1 },
}
: {},
});
it.each<{ description: string; input: FieldSparkline; warning?: boolean }>([
{
description: 'x=time, y=number, 5 values',
input: {
x: timeField('x', 5),
y: numField('y', 5),
},
},
};
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
).not.toThrow();
});
it('should not throw an error if there is a single value', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000],
type: FieldType.time,
config: {},
{
description: 'x=time, y=number, 1 value',
input: {
x: timeField('x', 1),
y: numField('y', 1),
},
warning: true,
},
y: {
name: 'y',
values: [1],
type: FieldType.number,
config: {},
state: {
range: { min: 1, max: 1, delta: 0 },
{
description: 'x=time, y=number, 0 values',
input: {
x: timeField('x', 0),
y: numField('y', 0),
},
warning: true,
},
{
description: 'x=time (unordered), y=number, 5 values',
input: {
x: {
...timeField('x', 5),
values: timeField('x', 5).values.reverse(),
},
y: timeField('y', 5),
},
warning: true,
},
{
description: 'x=number, y=number, 5 values',
input: {
x: numField('x', 5),
y: numField('y', 5),
},
},
};
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
).not.toThrow();
});
{
description: 'x=number, y=number, 1 value',
input: {
x: numField('x', 1),
y: numField('y', 1),
},
warning: true,
},
{
description: 'x=number, y=number, 0 values',
input: {
x: numField('x', 0),
y: numField('y', 0),
},
warning: true,
},
{
description: 'y=number, 5 values',
input: {
y: numField('y', 5),
},
},
{
description: 'y=number, 1 value',
input: {
y: numField('y', 1),
},
warning: true,
},
{
description: 'y=number, 0 values',
input: {
y: numField('y', 0),
},
warning: true,
},
])('does not throw for "$description"', ({ input, warning }) => {
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={input} />)
).not.toThrow();
it('should not throw an error if there are no values', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [],
type: FieldType.number,
config: {},
state: {},
},
};
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
).not.toThrow();
if (warning) {
expect(screen.getByRole('alert')).toBeInTheDocument();
} else {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByTestId('uplot-main-div')).toBeInTheDocument();
}
});
});
});
@@ -1,9 +1,14 @@
import { css } from '@emotion/css';
import React, { memo } from 'react';
import { FieldConfig, FieldSparkline } from '@grafana/data';
import { colorManipulator, FieldConfig, FieldSparkline, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { GraphFieldConfig } from '@grafana/schema';
import { useStyles2 } from '../../themes/ThemeContext';
import { Themeable2 } from '../../types/theme';
import { Icon } from '../Icon/Icon';
import { Tooltip } from '../Tooltip/Tooltip';
import { UPlotChart } from '../uPlot/Plot';
import { preparePlotData2, getStackingGroups } from '../uPlot/utils';
@@ -16,12 +21,59 @@ export interface SparklineProps extends Themeable2 {
sparkline: FieldSparkline;
}
const CompactAlert = ({ children, width }: { width: number; children: string | React.ReactElement }) => {
const styles = useStyles2(getCompactAlertStyles);
return (
<div role="alert" style={{ width, display: 'flex', justifyContent: 'center' }}>
{width >= 400 ? (
<div role="alert" className={styles.content}>
<Icon className={styles.icon} name="exclamation-triangle" />
{children}
</div>
) : (
<Tooltip content={children} placement="top">
<div className={styles.content}>
<Icon className={styles.icon} size="lg" name="exclamation-triangle" />
<Trans i18nKey="grafana-ui.components.sparkline.alert.title">Cannot render sparkline</Trans>
</div>
</Tooltip>
)}
</div>
);
};
const getCompactAlertStyles = (theme: GrafanaTheme2) => ({
content: css({
margin: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
padding: theme.spacing(0.5, 1),
color: theme.colors.warning.contrastText,
background: colorManipulator.alpha(theme.colors.warning.main, 0.85),
borderRadius: theme.shape.radius.default,
display: 'flex',
justifyContent: 'center',
'& a': {
color: theme.colors.warning.contrastText,
textDecoration: 'underline',
'&:hover': {
textDecoration: 'none',
},
},
}),
icon: css({
marginRight: theme.spacing(1),
}),
});
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
if (warning) {
return null;
return <CompactAlert width={width}>{warning}</CompactAlert>;
}
const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame));
@@ -123,6 +123,15 @@ export const prepareSeries = (
frame,
};
}
if (sparkline.x && !isLikelyAscendingVector(sparkline.x.values)) {
return {
warning: t(
'grafana-ui.components.sparkline.warning.x-not-ascending',
"The data in your Sparkline's x series must be sorted in ascending order."
),
frame,
};
}
return { frame };
};
+5 -1
View File
@@ -8695,8 +8695,12 @@
},
"components": {
"sparkline": {
"alert": {
"title": "Cannot render sparkline"
},
"warning": {
"too-few-values": "Sparkline requires at least two values to render."
"too-few-values": "Sparkline requires at least two values to render.",
"x-not-ascending": "The data in your Sparkline's x series must be sorted in ascending order."
}
}
},