From 65e740fe9ccdcc1a3017f1a5194e066ce9014d57 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 21 Nov 2025 13:58:41 -0500 Subject: [PATCH] Sparkline: Add warnings for invalid series, and add more test cases --- .../components/Sparkline/Sparkline.test.tsx | 175 ++++++++++++------ .../src/components/Sparkline/Sparkline.tsx | 56 +++++- .../src/components/Sparkline/utils.ts | 9 + public/locales/en-US/grafana.json | 6 +- 4 files changed, 182 insertions(+), 64 deletions(-) diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.test.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.test.tsx index 91f3d9945f3..d2020e4c562 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.test.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.test.tsx @@ -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() - ).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() - ).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() + ).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() - ).not.toThrow(); + if (warning) { + expect(screen.getByRole('alert')).toBeInTheDocument(); + } else { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByTestId('uplot-main-div')).toBeInTheDocument(); + } + }); }); }); diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index 68b1a5a832f..6975db48b28 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -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 ( +
+ {width >= 400 ? ( +
+ + {children} +
+ ) : ( + +
+ + Cannot render sparkline +
+
+ )} +
+ ); +}; + +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 = memo((props) => { const { sparkline, config: fieldConfig, theme, width, height } = props; const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig); if (warning) { - return null; + return {warning}; } const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index fa07cafb604..43137d12e52 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -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 }; }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 50a8504a38a..b2b57f26999 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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." } } },