StateTimeline: Complete E2E tests and add simple StateTimelineTooltip unit tests (#112901)

* StateTimeline: E2E tests for basic rendering, no data, and tooltip interactions

* Add tests for StateTimelineTooltip sub-component

* Remove unused import from state-timeline.spec.ts

* updates from PR review
This commit is contained in:
Paul Marbach
2025-10-24 10:45:48 -04:00
committed by GitHub
parent f7eac7f7ba
commit 3f02f50cf9
9 changed files with 388 additions and 20 deletions
+1
View File
@@ -482,6 +482,7 @@
/e2e-playwright/panels-suite/panelEdit_base.spec.ts @grafana/dashboards-squad
/e2e-playwright/panels-suite/panelEdit_queries.spec.ts @grafana/dashboards-squad
/e2e-playwright/panels-suite/panelEdit_transforms.spec.ts @grafana/datapro
/e2e-playwright/panels-suite/state-timeline.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-footer.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-markdown.spec.ts @grafana/dataviz-squad
@@ -1069,6 +1069,74 @@
],
"title": "boolean values from data",
"type": "state-timeline"
},
{
"id": 15,
"type": "state-timeline",
"title": "no data",
"gridPos": {
"x": 18,
"y": 18,
"h": 9,
"w": 6
},
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 0,
"fillOpacity": 70,
"spanNulls": false,
"insertNulls": false,
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"axisPlacement": "auto"
},
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
}
]
},
"fieldMinMax": false
},
"overrides": []
},
"pluginVersion": "12.3.0-pre",
"targets": [
{
"scenarioId": "random_walk",
"refId": "A",
"seriesCount": 0
}
],
"datasource": {
"type": "grafana-testdata-datasource"
},
"options": {
"mergeValues": true,
"showValue": "auto",
"alignValue": "center",
"rowHeight": 0.9,
"legend": {
"showLegend": true,
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none",
"hideZeros": false
}
}
}
],
"preload": false,
@@ -1092,4 +1160,4 @@
"title": "StateTimeline - Thresholds \u0026 Mappings",
"uid": "Kce7z9TVz",
"weekStart": ""
}
}
@@ -1069,6 +1069,75 @@
],
"title": "boolean values from data",
"type": "state-timeline"
},
{
"id": 15,
"type": "state-timeline",
"title": "no data",
"gridPos": {
"x": 18,
"y": 18,
"h": 9,
"w": 6
},
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 0,
"fillOpacity": 70,
"spanNulls": false,
"insertNulls": false,
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"axisPlacement": "auto"
},
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"fieldMinMax": false
},
"overrides": []
},
"pluginVersion": "12.3.0-pre",
"targets": [
{
"scenarioId": "random_walk",
"refId": "A",
"seriesCount": 0
}
],
"datasource": {
"type": "grafana-testdata-datasource"
},
"options": {
"mergeValues": true,
"showValue": "auto",
"alignValue": "center",
"rowHeight": 0.9,
"legend": {
"showLegend": true,
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none",
"hideZeros": false
}
}
}
],
"preload": false,
@@ -1086,5 +1155,5 @@
"timezone": "utc",
"title": "StateTimeline - Thresholds & Mappings",
"uid": "Kce7z9TVz",
"version": 18
"version": 19
}
@@ -0,0 +1,89 @@
import { test, expect } from '@grafana/plugin-e2e';
const DASHBOARD_UID = 'Kce7z9TVz';
test.use({
viewport: { width: 1280, height: 2000 },
});
test.describe('Panels test: StateTimeine', { tag: ['@panels', '@state-timeline'] }, () => {
test('renders successfully', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
});
// check that gauges are rendered
const stateTimelineUplot = page.locator('.uplot');
await expect(stateTimelineUplot, 'panels are rendered').toHaveCount(11);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo, 'no errors in the panels').toBeHidden();
});
test('"no data"', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '15' }),
});
const stateTimelineUplot = page.locator('.uplot');
await expect(stateTimelineUplot, "that uplot doesn't appear").toBeHidden();
const emptyMessage = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage);
await expect(emptyMessage, 'that the empty text appears').toHaveText('No data');
// update the "No value" option and see if the panel updates
const noValueOption = dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value'))
.locator('input');
await noValueOption.fill('My empty value');
await noValueOption.blur();
await expect(emptyMessage, 'that the empty text has changed').toHaveText('My empty value');
});
test('tooltip interactions', async ({ gotoDashboardPage, page, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '13' }),
});
const stateTimelineUplot = page.locator('.uplot');
await expect(stateTimelineUplot, 'uplot is rendered').toBeVisible();
const tooltip = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Tooltip.Wrapper);
// hover over a spot to trigger the tooltip
await stateTimelineUplot.hover({ position: { x: 100, y: 50 } });
await expect(tooltip, 'tooltip appears on hover').toBeVisible();
await expect(tooltip, 'tooltip displays the value').toContainText('value5');
await expect(tooltip, 'tooltip displays the duration').toContainText('Duration1h');
// click to pin the tooltip, hover away to be sure it's pinned
await stateTimelineUplot.click({ position: { x: 100, y: 50 } });
await stateTimelineUplot.hover({ position: { x: 300, y: 50 } });
await expect(tooltip, 'tooltip pinned on click').toBeVisible();
await expect(tooltip, 'tooltip displays the first value').toContainText('value5');
await expect(tooltip, 'tooltip displays the first duration').toContainText('Duration1h');
// unpin the tooltip, ensure it closes on hover away
await stateTimelineUplot.click({ position: { x: 300, y: 50 } });
await stateTimelineUplot.blur();
await expect(tooltip, 'tooltip closed after unpinning and hovering away').toBeHidden();
// test clicking the "x" as well
await stateTimelineUplot.click({ position: { x: 100, y: 50 } });
await expect(tooltip, 'tooltip appears on click').toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click();
await expect(tooltip, 'tooltip closed on "x" click').toBeHidden();
// disable tooltips
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode'))
.getByLabel('Hidden')
.click();
await stateTimelineUplot.hover({ position: { x: 100, y: 50 } });
await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden();
});
});
@@ -516,6 +516,11 @@ export const versionedComponents = {
},
},
},
Tooltip: {
Wrapper: {
'12.3.0': 'data-testid viz-tooltip-wrapper',
},
},
},
},
VizLegend: {
@@ -11,13 +11,11 @@ interface Props {
item: VizTooltipItem;
isPinned: boolean;
}
export const VizTooltipHeader = ({ item, isPinned }: Props) => {
export const VizTooltipHeader = ({ item: { label, value, color, colorIndicator }, isPinned }: Props) => {
const styles = useStyles2(getStyles);
const { label, value, color, colorIndicator } = item;
return (
<div className={styles.wrapper}>
<div className={styles}>
<VizTooltipRow
label={label}
value={value}
@@ -30,12 +28,11 @@ export const VizTooltipHeader = ({ item, isPinned }: Props) => {
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
const getStyles = (theme: GrafanaTheme2) =>
css({
display: 'flex',
flexDirection: 'column',
flex: 1,
padding: theme.spacing(1),
lineHeight: 1,
}),
});
});
@@ -1,7 +1,9 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import clsx from 'clsx';
import React, { HTMLAttributes } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '../../themes/ThemeContext';
@@ -11,14 +13,16 @@ export interface Props extends HTMLAttributes<HTMLDivElement> {
export const VizTooltipWrapper = ({ children, className }: Props) => {
const styles = useStyles2(getStyles);
return <div className={cx(styles.wrapper, className)}>{children}</div>;
return (
<div className={clsx(styles, className)} data-testid={selectors.components.Panels.Visualization.Tooltip.Wrapper}>
{children}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
const getStyles = (theme: GrafanaTheme2) =>
css({
display: 'flex',
flexDirection: 'column',
fontSize: theme.typography.bodySmall.fontSize,
}),
});
});
@@ -0,0 +1,135 @@
import { render, screen } from '@testing-library/react';
import { createDataFrame, Field, FieldType, makeTimeRange } from '@grafana/data';
import { TooltipDisplayMode } from '@grafana/ui';
import { StateTimelineTooltip } from './StateTimelineTooltip';
describe('StateTimelineTooltip', () => {
const timeRange = makeTimeRange('1970-01-01T00:00:00+00:00', '1970-01-01T00:02:00+00:00');
const timeField: Field = {
name: 'Time',
type: FieldType.time,
values: [0, 60000, 100000],
display: (v) => ({ text: String(v), numeric: NaN }),
config: {},
};
const valueField: Field = {
name: 'State',
type: FieldType.number,
values: [0, 100, 50],
display: (v) => ({ text: String(v), numeric: Number(v) }),
config: {},
};
const series = createDataFrame({ fields: [timeField, valueField] });
describe('Duration display', () => {
it('should include the duration in single mode when withDuration is true', () => {
render(
<StateTimelineTooltip
series={series}
seriesIdx={1}
dataIdxs={[null, 1]}
mode={TooltipDisplayMode.Single}
timeRange={timeRange}
withDuration
dataLinks={[]}
isPinned={false}
/>
);
expect(screen.queryByText('State')).toBeInTheDocument();
expect(screen.queryByText('100')).toBeInTheDocument();
expect(screen.queryByText('Duration')).toBeInTheDocument();
expect(screen.queryByText('40s')).toBeInTheDocument(); // 100000 - 60000 = 40000 ms = 40s
});
it('correctly renders the final duration in a state timeline based on the range', () => {
render(
<StateTimelineTooltip
series={series}
seriesIdx={1}
dataIdxs={[null, 2]}
mode={TooltipDisplayMode.Single}
timeRange={timeRange}
withDuration
dataLinks={[]}
isPinned={false}
/>
);
expect(screen.queryByText('Duration')).toBeInTheDocument();
expect(screen.queryByText('20s')).toBeInTheDocument(); // 120000 - 100000 = 20000 ms = 20s
});
it('should not include the duration in multi mode even when withDuration is true', () => {
render(
<StateTimelineTooltip
series={createDataFrame({
fields: [
timeField,
{ ...valueField, name: 'StateA' },
{ ...valueField, name: 'StateB', values: [200, 400, 100] },
],
})}
seriesIdx={1}
dataIdxs={[null, 1, 1]}
mode={TooltipDisplayMode.Multi}
timeRange={timeRange}
withDuration
dataLinks={[]}
isPinned={false}
/>
);
expect(screen.queryByText('StateA')).toBeInTheDocument();
expect(screen.queryByText('StateB')).toBeInTheDocument();
expect(screen.queryByText('Duration')).not.toBeInTheDocument();
});
it('works without a seriesIdx is withDuration is false', () => {
render(
<StateTimelineTooltip
series={series}
dataIdxs={[null, 1]}
mode={TooltipDisplayMode.Single}
timeRange={timeRange}
withDuration={false}
dataLinks={[]}
isPinned={false}
/>
);
expect(screen.queryByText('60000')).toBeInTheDocument();
expect(screen.queryByText('State')).not.toBeInTheDocument();
expect(screen.queryByText('Duration')).not.toBeInTheDocument();
});
});
describe('footer', () => {
const StateTimelineTooltipWithDataLinks = ({ isPinned }: { isPinned: boolean }) => (
<StateTimelineTooltip
series={series}
seriesIdx={1}
dataIdxs={[null, 1]}
mode={TooltipDisplayMode.Single}
timeRange={timeRange}
withDuration
dataLinks={[{ title: 'Regular Link', href: 'https://example.com', target: '_blank', origin: '*' }]}
isPinned={isPinned}
/>
);
it('shows the footer if isPinned is true and there are data links', () => {
render(<StateTimelineTooltipWithDataLinks isPinned />);
const link = screen.queryByText('Regular Link');
expect(link).toBeInTheDocument();
});
it('hides the footer if isPinned is false even if there are data links', () => {
render(<StateTimelineTooltipWithDataLinks isPinned={false} />);
const link = screen.queryByText('Regular Link');
expect(link).not.toBeInTheDocument();
});
});
});
@@ -53,8 +53,8 @@ export const StateTimelineTooltip = ({
const field = series.fields[seriesIdx!];
const nextStateIdx = findNextStateIndex(field, dataIdx!);
let nextStateTs;
if (nextStateIdx) {
nextStateTs = xField.values[nextStateIdx!];
if (nextStateIdx != null) {
nextStateTs = xField.values[nextStateIdx];
}
const stateTs = xField.values[dataIdx!];