Sparkline: Add warnings for invalid series, and add more test cases
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user