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:
@@ -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
|
||||
|
||||
+69
-1
@@ -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!];
|
||||
|
||||
Reference in New Issue
Block a user