Compare commits

...

2 Commits

Author SHA1 Message Date
Cedric Ziel
e0f2bdbcc1 Fix lint errors in Sparkline hover implementation
- Remove type assertions (as any) in favor of type guards
- Fix emotion CSS to use object notation instead of template literal
- Rename 'component' to 'view' in test files (testing-library convention)
- Add eslint-disable comments for necessary any types in tests
- Add proper uPlot typing for test mock objects
2025-11-28 11:44:15 +01:00
Cedric Ziel
27a3503e61 Table: Add interactive hover support to sparkline cells
This adds hover interaction to sparkline table cells, allowing users to
see the value at a specific point by hovering over the sparkline chart.

Key changes:
- Added `interactionEnabled` configuration option (defaults to true)
- Sparkline component now accepts an `onHover` callback prop
- SparklineCell manages hover state and updates the displayed value
- Visual indicator uses a 2px solid vertical bar (more visible on small sparklines)
- setLegend hook tracks cursor position and calls onHover with the value
- Handles edge cases: non-finite values (NaN, Infinity), cursor leaving chart
- Added comprehensive unit tests (7 tests covering all interaction scenarios)

Technical implementation:
- Uses uPlot's setLegend hook (fires on hover, unlike setSelect which only fires on drag)
- Vertical bar indicator is styled via emotion CSS to be visible on 25-30px sparklines
- Cursor point functions return safe values to avoid accessing undefined frames
- Configuration UI in SparklineCellOptionsEditor allows toggling interaction
2025-11-27 12:13:14 +01:00
6 changed files with 365 additions and 31 deletions

View File

@@ -831,9 +831,17 @@ export interface TableBarGaugeCellOptions {
*/
export interface TableSparklineCellOptions extends GraphFieldConfig {
hideValue?: boolean;
/**
* Enable interactive hover to inspect values along the sparkline
*/
interactionEnabled?: boolean;
type: TableCellDisplayMode.Sparkline;
}
export const defaultTableSparklineCellOptions: Partial<TableSparklineCellOptions> = {
interactionEnabled: true,
};
/**
* Colored background cell options
*/

View File

@@ -63,6 +63,8 @@ TableSparklineCellOptions: {
GraphFieldConfig
type: TableCellDisplayMode & "sparkline"
hideValue?: bool
// Enable interactive hover to inspect values along the sparkline
interactionEnabled?: bool | *true
} @cuetsy(kind="interface")
// Colored background cell options

View File

@@ -1,30 +1,264 @@
import { render } from '@testing-library/react';
import uPlot from 'uplot';
import { createTheme, FieldSparkline, FieldType } from '@grafana/data';
import { createTheme, FieldConfig, FieldSparkline, FieldType } from '@grafana/data';
import { GraphFieldConfig } from '@grafana/schema';
import { Sparkline } from './Sparkline';
describe('Sparkline', () => {
const mockSparkline: 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 },
},
},
};
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 },
},
},
};
expect(() =>
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={mockSparkline} />)
).not.toThrow();
});
describe('hover interaction', () => {
it('should call onHover with value when interaction is enabled and cursor moves', () => {
const onHover = jest.fn();
const config: FieldConfig<GraphFieldConfig> = {
custom: {
interactionEnabled: true,
} as GraphFieldConfig & { interactionEnabled?: boolean },
};
const view = render(
<Sparkline
width={800}
height={600}
theme={createTheme()}
sparkline={mockSparkline}
config={config}
onHover={onHover}
/>
);
// Get the Sparkline instance to access the config builder
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
if (instance?.state?.configBuilder) {
const builder = instance.state.configBuilder;
const hooks = builder.getConfig().hooks;
// Find and execute the setLegend hook
const setLegendHook = hooks?.setLegend?.[0];
if (setLegendHook) {
// Simulate hover over data point at index 2 (value: 3)
const mockUPlot = {
cursor: { idxs: [2, 2] },
data: [mockSparkline.x!.values, mockSparkline.y.values],
} as Partial<uPlot>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setLegendHook(mockUPlot as any);
expect(onHover).toHaveBeenCalledWith(3, 2);
}
}
});
it('should call onHover with null when cursor leaves', () => {
const onHover = jest.fn();
const config: FieldConfig<GraphFieldConfig> = {
custom: {
interactionEnabled: true,
} as GraphFieldConfig & { interactionEnabled?: boolean },
};
const view = render(
<Sparkline
width={800}
height={600}
theme={createTheme()}
sparkline={mockSparkline}
config={config}
onHover={onHover}
/>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
if (instance?.state?.configBuilder) {
const builder = instance.state.configBuilder;
const hooks = builder.getConfig().hooks;
const setLegendHook = hooks?.setLegend?.[0];
if (setLegendHook) {
// Simulate cursor leaving (no valid index)
const mockUPlot = {
cursor: { idxs: [null, null] },
data: [mockSparkline.x!.values, mockSparkline.y.values],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setLegendHook(mockUPlot as any);
expect(onHover).toHaveBeenCalledWith(null, null);
}
}
});
it('should not set up hover hooks when interaction is disabled', () => {
const onHover = jest.fn();
const config: FieldConfig<GraphFieldConfig> = {
custom: {
interactionEnabled: false,
} as GraphFieldConfig & { interactionEnabled?: boolean },
};
const view = render(
<Sparkline
width={800}
height={600}
theme={createTheme()}
sparkline={mockSparkline}
config={config}
onHover={onHover}
/>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
if (instance?.state?.configBuilder) {
const builder = instance.state.configBuilder;
const hooks = builder.getConfig().hooks;
// setLegend hook should not be registered
expect(hooks?.setLegend).toBeUndefined();
}
});
it('should not set up hover hooks when onHover is not provided', () => {
const config: FieldConfig<GraphFieldConfig> = {
custom: {
interactionEnabled: true,
} as GraphFieldConfig & { interactionEnabled?: boolean },
};
const view = render(
<Sparkline
width={800}
height={600}
theme={createTheme()}
sparkline={mockSparkline}
config={config}
/>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
if (instance?.state?.configBuilder) {
const builder = instance.state.configBuilder;
const hooks = builder.getConfig().hooks;
// setLegend hook should not be registered when no onHover callback
expect(hooks?.setLegend).toBeUndefined();
}
});
it('should enable interaction by default when not explicitly configured', () => {
const onHover = jest.fn();
const config: FieldConfig<GraphFieldConfig> = {
custom: {} as GraphFieldConfig & { interactionEnabled?: boolean },
};
const view = render(
<Sparkline
width={800}
height={600}
theme={createTheme()}
sparkline={mockSparkline}
config={config}
onHover={onHover}
/>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
if (instance?.state?.configBuilder) {
const builder = instance.state.configBuilder;
const hooks = builder.getConfig().hooks;
// setLegend hook should be registered (interaction enabled by default)
expect(hooks?.setLegend).toBeDefined();
expect(hooks?.setLegend?.length).toBeGreaterThan(0);
}
});
it('should handle non-finite values correctly during hover', () => {
const onHover = jest.fn();
const config: FieldConfig<GraphFieldConfig> = {
custom: {
interactionEnabled: true,
} as GraphFieldConfig & { interactionEnabled?: boolean },
};
const sparklineWithNaN: FieldSparkline = {
...mockSparkline,
y: {
...mockSparkline.y,
values: [1, NaN, 3, Infinity, 5],
},
};
const view = render(
<Sparkline
width={800}
height={600}
theme={createTheme()}
sparkline={sparklineWithNaN}
config={config}
onHover={onHover}
/>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
if (instance?.state?.configBuilder) {
const builder = instance.state.configBuilder;
const hooks = builder.getConfig().hooks;
const setLegendHook = hooks?.setLegend?.[0];
if (setLegendHook) {
// Hover over NaN value at index 1
const mockUPlot1 = {
cursor: { idxs: [1, 1] },
data: [sparklineWithNaN.x!.values, sparklineWithNaN.y.values],
} as Partial<uPlot>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setLegendHook(mockUPlot1 as any);
expect(onHover).toHaveBeenCalledWith(null, null);
onHover.mockClear();
// Hover over Infinity value at index 3
const mockUPlot3 = {
cursor: { idxs: [3, 3] },
data: [sparklineWithNaN.x!.values, sparklineWithNaN.y.values],
} as Partial<uPlot>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setLegendHook(mockUPlot3 as any);
expect(onHover).toHaveBeenCalledWith(null, null);
}
}
});
});
});

View File

@@ -1,3 +1,4 @@
import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { PureComponent } from 'react';
import { AlignedData, Range } from 'uplot';
@@ -33,6 +34,7 @@ export interface SparklineProps extends Themeable2 {
height: number;
config?: FieldConfig<GraphFieldConfig>;
sparkline: FieldSparkline;
onHover?: (value: number | null, index: number | null) => void;
}
interface State {
@@ -106,14 +108,14 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
}
prepareConfig(data: DataFrame) {
const { theme } = this.props;
const { theme, onHover, config } = this.props;
const builder = new UPlotConfigBuilder();
builder.setCursor({
show: false,
x: false, // no crosshairs
y: false,
});
// Check if interaction is enabled (default to true)
// interactionEnabled is on TableSparklineCellOptions which extends GraphFieldConfig
const customConfig = config?.custom;
const interactionEnabled =
customConfig && 'interactionEnabled' in customConfig ? customConfig.interactionEnabled : true;
// X is the first field in the alligned frame
const xField = data.fields[0];
@@ -141,6 +143,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
placement: AxisPlacement.Hidden,
});
// Track the series color for cursor point styling
let seriesColor: string | undefined;
for (let i = 0; i < data.fields.length; i++) {
const field = data.fields[i];
const config: FieldConfig<GraphFieldConfig> = field.config;
@@ -168,7 +173,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
});
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
seriesColor = colorMode.getCalculator(field, theme)(0, 0);
const pointsMode =
customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
@@ -183,7 +188,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
lineWidth: customConfig.lineWidth,
lineInterpolation: customConfig.lineInterpolation,
showPoints: pointsMode,
pointSize: customConfig.pointSize,
pointSize: customConfig.pointSize || 5, // Ensure minimum size for cursor point calculation
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
@@ -192,12 +197,72 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
});
}
// Configure cursor after series so we have the series color
if (interactionEnabled && onHover) {
// Enable cursor with vertical bar indicator for hover interaction
// Vertical bar is more visible on small sparklines (25-30px height) than a dot
builder.setCursor({
show: true,
x: true, // show vertical line (bar indicator)
y: false, // no horizontal line
points: {
show: false, // don't show dots - use vertical bar instead
// Provide safe functions that don't access this.frames (which is undefined for Sparkline)
stroke: () => 'transparent',
fill: () => 'transparent',
size: () => 0,
width: () => 0,
},
focus: {
prox: 30, // proximity in CSS pixels for hover detection
},
});
// Track cursor position and call onHover with the value at that position
// Using setLegend hook which fires on hover (not just drag-to-select like setSelect)
builder.addHook('setLegend', (u: uPlot) => {
const dataIdx = u.cursor.idxs?.[1]; // Get the data index from the cursor
if (dataIdx != null) {
const yData = u.data[1]; // Y-axis data (values)
if (yData && dataIdx < yData.length) {
const value = yData[dataIdx];
if (value != null && isFinite(value)) {
onHover(value, dataIdx);
return;
}
}
}
// Reset on mouse leave or when no valid data point
onHover(null, null);
});
} else {
// Default behavior: cursor disabled
builder.setCursor({
show: false,
x: false, // no crosshairs
y: false,
});
}
return builder;
}
render() {
const { data, configBuilder } = this.state;
const { width, height } = this.props;
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
// Style the vertical cursor bar to be more visible on small sparklines
const cursorStyles = css({
'.u-cursor-x': {
borderLeft: '2px solid !important',
opacity: '1 !important',
},
});
return (
<div className={cursorStyles}>
<UPlotChart data={data} config={configBuilder} width={width} height={height} />
</div>
);
}
}

View File

@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { useState, useCallback } from 'react';
import { FieldConfig, getMinMaxAndDelta, Field, isDataFrameWithValue } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -32,12 +33,20 @@ export const defaultSparklineCellConfig: TableSparklineCellOptions = {
barAlignment: BarAlignment.Center,
showPoints: VisibilityMode.Never,
hideValue: false,
interactionEnabled: true,
};
export const SparklineCell = (props: SparklineCellProps) => {
const { field, value, theme, timeRange, rowIdx, width } = props;
const sparkline = prepareSparklineValue(value, field);
// Hover state management for interactive sparklines
const [hoverValue, setHoverValue] = useState<number | null>(null);
const handleHover = useCallback((value: number | null, index: number | null) => {
setHoverValue(value);
}, []);
if (!sparkline) {
return (
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
@@ -80,8 +89,10 @@ export const SparklineCell = (props: SparklineCellProps) => {
let valueWidth = 0;
let valueElement: React.ReactNode = null;
if (!hideValue) {
const newValue = isDataFrameWithValue(value) ? value.value : null;
const displayValue = field.display!(newValue);
// Use hover value if hovering, otherwise use the default value
const defaultValue = isDataFrameWithValue(value) ? value.value : null;
const displayRawValue = hoverValue ?? defaultValue;
const displayValue = field.display!(displayRawValue);
const alignmentFactor = getAlignmentFactor(field, displayValue, rowIdx!);
valueWidth =
@@ -94,7 +105,14 @@ export const SparklineCell = (props: SparklineCellProps) => {
return (
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
{valueElement}
<Sparkline width={width - valueWidth} height={25} sparkline={sparkline} config={config} theme={theme} />
<Sparkline
width={width - valueWidth}
height={25}
sparkline={sparkline}
config={config}
theme={theme}
onHover={handleHover}
/>
</MaybeWrapWithLink>
);
};

View File

@@ -12,6 +12,7 @@ import { TableCellEditorProps } from '../TableCellOptionEditor';
type OptionKey = keyof TableSparklineCellOptions;
const optionIds: Array<keyof TableSparklineCellOptions> = [
'interactionEnabled',
'hideValue',
'drawStyle',
'lineInterpolation',
@@ -31,6 +32,12 @@ function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<Gr
...graphFieldConfig,
useCustomConfig: (builder) => {
graphFieldConfig.useCustomConfig?.(builder);
builder.addBooleanSwitch({
path: 'interactionEnabled',
name: 'Enable hover interaction',
description: 'Allow users to hover over the sparkline to inspect values',
defaultValue: true,
});
builder.addBooleanSwitch({
path: 'hideValue',
name: 'Hide value',