mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 21:00:38 +01:00
Compare commits
5 Commits
e2e/dashbo
...
e2e/table-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8259d0777b | ||
|
|
a2a70605e0 | ||
|
|
c27ffafd0f | ||
|
|
77f6e3c4fa | ||
|
|
0b42daf39f |
@@ -93,6 +93,7 @@ function ValueGraph({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="value-graph-container"
|
||||
data-testid="value-graph-container"
|
||||
style={{
|
||||
backgroundColor:
|
||||
threshold.thresholdFormat === 'Background'
|
||||
|
||||
@@ -159,6 +159,8 @@ function GridTableComponent({
|
||||
if (threshold && idx !== -1) {
|
||||
return (
|
||||
<div
|
||||
data-testid="threshold-styled-cell"
|
||||
data-threshold-format={threshold.thresholdFormat}
|
||||
style={
|
||||
threshold.thresholdFormat === 'Background'
|
||||
? { backgroundColor: threshold.thresholdColor }
|
||||
|
||||
@@ -80,18 +80,23 @@ export function ColumnUnitSelector(
|
||||
{aggregationQueries.map(({ value, label }) => {
|
||||
const baseQueryName = value.split('.')[0];
|
||||
return (
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
<div
|
||||
key={value}
|
||||
data-testid={props['data-testid']}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
className="column-unit-row"
|
||||
data-testid={`column-unit-row-${baseQueryName}`}
|
||||
>
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
data-testid={props['data-testid']}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,10 @@ export default function VisualizationSettingsSection({
|
||||
>
|
||||
{graphTypes.map((item) => (
|
||||
<Option key={item.name} value={item.name}>
|
||||
<div className="select-option">
|
||||
<div
|
||||
className="select-option"
|
||||
data-testid={`panel-type-option-${item.name}`}
|
||||
>
|
||||
<div className="icon">{item.icon}</div>
|
||||
<Typography.Text className="display">{item.display}</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -231,12 +231,14 @@ function Threshold({
|
||||
type="text"
|
||||
icon={<Pencil size={14} />}
|
||||
className="edit-btn"
|
||||
data-testid="threshold-edit-btn"
|
||||
onClick={editHandler}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Trash2 size={14} />}
|
||||
className="delete-btn"
|
||||
data-testid="threshold-delete-btn"
|
||||
onClick={deleteHandler}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -370,12 +370,32 @@ export async function findDashboardIdByTitle(
|
||||
return body.data.find((d) => d.data.title === title)?.id;
|
||||
}
|
||||
|
||||
/** Shape of a single persisted widget — only the fields these specs assert on. */
|
||||
export interface PersistedWidget {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
panelTypes?: string;
|
||||
timePreferance?: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: number;
|
||||
thresholds?: Array<{
|
||||
thresholdFormat?: string;
|
||||
thresholdOperator?: string;
|
||||
thresholdValue?: number;
|
||||
thresholdColor?: string;
|
||||
thresholdTableOptions?: string;
|
||||
}>;
|
||||
columnUnits?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Shape of the persisted dashboard payload returned by GET /api/v1/dashboards/<id>. */
|
||||
export interface DashboardData {
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
widgets?: Array<{ id?: string; title?: string }>;
|
||||
widgets?: PersistedWidget[];
|
||||
variables?: Record<string, Record<string, unknown>>;
|
||||
layout?: unknown[];
|
||||
}
|
||||
@@ -560,3 +580,106 @@ export async function configureAndSavePanel(
|
||||
// Save navigates back to /dashboard/<id> (no /new suffix).
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
}
|
||||
|
||||
// ─── Widget editor (re-open existing panel) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Display labels surfaced in the `panel-change-select` Ant Select inside the
|
||||
* widget editor. Mirrors the `PanelDisplay` enum in
|
||||
* `frontend/src/constants/queryBuilder.ts` — keep in sync if a label is added,
|
||||
* removed, or renamed there.
|
||||
*/
|
||||
export type PanelDisplayLabel =
|
||||
| 'Time Series'
|
||||
| 'Number'
|
||||
| 'Table'
|
||||
| 'List'
|
||||
| 'Bar'
|
||||
| 'Pie'
|
||||
| 'Histogram';
|
||||
|
||||
/**
|
||||
* Maps each display label to the URL `graphType` value. The right-hand strings
|
||||
* mirror the `PANEL_TYPES` enum in
|
||||
* `frontend/src/constants/queryBuilder.ts` (TIME_SERIES='graph',
|
||||
* VALUE='value', and so on) — keep in sync with the enum.
|
||||
*/
|
||||
const PANEL_DISPLAY_TO_GRAPH_TYPE: Record<PanelDisplayLabel, string> = {
|
||||
'Time Series': 'graph',
|
||||
Number: 'value',
|
||||
Table: 'table',
|
||||
List: 'list',
|
||||
Bar: 'bar',
|
||||
Pie: 'pie',
|
||||
Histogram: 'histogram',
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the widget editor for an existing panel by driving the panel header
|
||||
* options menu (the three-dot Ant `Dropdown` next to the title).
|
||||
*
|
||||
* The widget-header-options button is `visibility: hidden` until the panel is
|
||||
* hovered (see `GridCardLayout.styles.scss`) — except on TABLE panels, where
|
||||
* `globalSearchAvailable` keeps it permanently visible. Hovering the title
|
||||
* testid first works for both states.
|
||||
*/
|
||||
export async function openWidgetEditor(
|
||||
page: Page,
|
||||
panelTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(panelTitle).first().hover();
|
||||
await page.getByTestId('widget-header-options').first().click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /^edit$/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/widgetId=/);
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Save Changes" in the widget editor, await the dashboard PUT response,
|
||||
* and wait for navigation back to `/dashboard/<id>`. Throws if the PUT
|
||||
* response is not 2xx. NewWidget's save handler calls the mutation and
|
||||
* navigates on success — there is no confirmation modal in this flow.
|
||||
*/
|
||||
export async function saveWidgetEdit(page: Page): Promise<void> {
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
const res = await putResponse;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the editor's panel display type via the Ant `Select` exposed as
|
||||
* `data-testid="panel-change-select"`. The select options carry the display
|
||||
* label as visible text (matches `PanelDisplay` enum values). After the
|
||||
* change, this helper waits for the URL `graphType` param to reflect the new
|
||||
* panel type and for the Save Changes button to re-render — the editor
|
||||
* re-routes mid-flow via `redirectWithQueryBuilderData`.
|
||||
*
|
||||
* Note: the "List" option is filtered out of the dropdown when the current
|
||||
* query contains a metrics data source (see VisualizationSettingsSection).
|
||||
*/
|
||||
export async function changePanelType(
|
||||
page: Page,
|
||||
displayLabel: PanelDisplayLabel,
|
||||
): Promise<void> {
|
||||
const expectedGraphType = PANEL_DISPLAY_TO_GRAPH_TYPE[displayLabel];
|
||||
await page.getByTestId('panel-change-select').click();
|
||||
// Each option renders a `data-testid="panel-type-option-<graphType>"` hook
|
||||
// on its inner `.select-option` wrapper (see VisualizationSettingsSection).
|
||||
// Targeting that is more stable than Ant's hidden-select option role —
|
||||
// whose accessible name is the URL `graphType` value, not the display label.
|
||||
await page.getByTestId(`panel-type-option-${expectedGraphType}`).click();
|
||||
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
255
tests/e2e/tests/dashboards/panels/list.spec.ts
Normal file
255
tests/e2e/tests/dashboards/panels/list.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
openWidgetEditor,
|
||||
saveWidgetEdit,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const FIXTURE_DASHBOARD_TITLE = 'list-controls-fixture';
|
||||
const FIXTURE_PANEL_TITLE = 'list-controls-panel';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
|
||||
// LIST panels require a logs (or traces) data source — metrics queries
|
||||
// hide the LIST option from panel-change-select.
|
||||
await configureAndSavePanel(page, 'logs', FIXTURE_PANEL_TITLE);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await changePanelType(page, 'List');
|
||||
await saveWidgetEdit(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoFixtureDashboard(page: Page): Promise<void> {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/** Fetch the persisted fixture dashboard's first widget. */
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
test.describe('List Panel Controls', () => {
|
||||
test('TC-01 panel name persists and is reflected in the widget header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('list-controls-renamed');
|
||||
await saveWidgetEdit(page);
|
||||
await expect(page.getByTestId('list-controls-renamed').first()).toBeVisible();
|
||||
|
||||
// Server-side check.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('list-controls-renamed');
|
||||
|
||||
await openWidgetEditor(page, 'list-controls-renamed');
|
||||
await expect(page.getByTestId('panel-name-input')).toHaveValue(
|
||||
'list-controls-renamed',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-02 description persists and shows info icon on header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('E2E list description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe('E2E list description');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page.getByTestId('panel-description-input')).toHaveValue(
|
||||
'E2E list description',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-03 panel type switch from List to Table persists and re-renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await changePanelType(page, 'Table');
|
||||
// Table re-renders Decimal Precision + Column Units in the right pane.
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Panel card should now render an Ant table head.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="' + FIXTURE_PANEL_TITLE + '"]')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.ant-table-thead').first()).toBeVisible();
|
||||
|
||||
// Server-side: panelTypes is 'table'.
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('table');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page).toHaveURL(/graphType=table/);
|
||||
|
||||
// Reset back to List.
|
||||
await changePanelType(page, 'List');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-04 sections hidden for LIST are not rendered in the right pane', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
|
||||
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
|
||||
await expect(page.locator('section.stack-chart')).toHaveCount(0);
|
||||
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
|
||||
await expect(page.locator('section.log-scale')).toHaveCount(0);
|
||||
await expect(page.locator('section.legend-position')).toHaveCount(0);
|
||||
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
|
||||
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
|
||||
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
|
||||
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-description-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-change-select')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 discarding right-pane changes does not persist', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('discard-list-test');
|
||||
|
||||
let putFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
putFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.getByTestId('discard-button').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.last()
|
||||
.getByRole('button', { name: /^OK$/i })
|
||||
.click({ timeout: 1000 })
|
||||
.catch(() => {
|
||||
// no modal — direct navigation
|
||||
});
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
|
||||
|
||||
// Settle before asserting no PUT.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Save description + a non-default panel type, then hard-reload and
|
||||
// re-verify the panel card rehydrates with the right state.
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Description info icon must render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Server-side check post-reload — confirms the load path read the same JSON.
|
||||
const persisted = await fetchFixtureWidget(page);
|
||||
expect(persisted.description).toBe('reload persistence description');
|
||||
expect(persisted.panelTypes).toBe('list');
|
||||
|
||||
// Reset.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
588
tests/e2e/tests/dashboards/panels/table.spec.ts
Normal file
588
tests/e2e/tests/dashboards/panels/table.spec.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
openWidgetEditor,
|
||||
saveWidgetEdit,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const FIXTURE_DASHBOARD_TITLE = 'table-controls-fixture';
|
||||
const FIXTURE_PANEL_TITLE = 'table-controls-panel';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
|
||||
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await changePanelType(page, 'Table');
|
||||
await saveWidgetEdit(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoFixtureDashboard(page: Page): Promise<void> {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the persisted fixture dashboard JSON and return the first widget.
|
||||
* Use this after a save to confirm the PUT actually landed the expected
|
||||
* shape on the backend — UI-only round-trips pass on optimistic-update bugs.
|
||||
*/
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last <td> in the first data row of the panel's Ant Design table.
|
||||
* Ant Design applies .ant-table-row to actual data rows only (not header rows),
|
||||
* so this correctly skips the fixed/sticky header tbody rows.
|
||||
*
|
||||
* For the metrics panel the row has: td[0] = label column, td[last] = value
|
||||
* column (the aggregation query "A"). The last td is thus the value cell.
|
||||
* However, depending on the panel query there may only be ONE td per row. Use
|
||||
* the cell that contains a non-empty value: any td that is not purely the
|
||||
* label placeholder.
|
||||
*
|
||||
* NOTE: the value cell wraps its text in a <button> element (from the
|
||||
* QueryTable open-traces render path) so textContent picks it up correctly.
|
||||
*/
|
||||
async function getFirstDataCell(page: Page) {
|
||||
// .ant-table-row targets Ant Design data rows only (not header/fixed rows).
|
||||
const firstRow = page.locator('tr.ant-table-row').first();
|
||||
await firstRow.waitFor({ state: 'visible' });
|
||||
// Return the last <td> — for a metrics table with columns [label, A] this
|
||||
// is the value column. For a single-column table it is the only column.
|
||||
return firstRow.locator('td').last();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a SettingsSection accordion in the widget editor right pane is
|
||||
* expanded. If it is already open (content div has the `open` class), this is
|
||||
* a no-op. Otherwise it clicks the header button and waits for the content to
|
||||
* become visible.
|
||||
*/
|
||||
async function expandSection(page: Page, title: string): Promise<void> {
|
||||
const section = page
|
||||
.locator('.settings-section')
|
||||
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
|
||||
const contentDiv = section.locator('.settings-section-content');
|
||||
const isOpen = await contentDiv.evaluate((el) => el.classList.contains('open'));
|
||||
if (!isOpen) {
|
||||
await section.locator('button.settings-section-header').click();
|
||||
await contentDiv.waitFor({ state: 'visible' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a unit from the column-unit selector dropdown by typing a search
|
||||
* term, then clicking the filtered option. Scoped to .column-unit-selector to
|
||||
* avoid matching the Y-axis unit selectors on other panel types.
|
||||
*
|
||||
* The selector has `showSearch` enabled and renders a long virtualised option
|
||||
* list — typing first avoids instability from the list re-rendering when the
|
||||
* target option is off-screen.
|
||||
*/
|
||||
async function selectColumnUnit(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionText: string,
|
||||
): Promise<void> {
|
||||
const row = page.getByTestId(/^column-unit-row-/).first();
|
||||
await row.click();
|
||||
// `showSearch` is enabled; the visible text input is rendered by Ant
|
||||
// inside the row but outside its testid wrapper post-focus, so reach in
|
||||
// via the remaining ant-select internals on the focused row.
|
||||
await row.locator('.ant-select input').fill(searchTerm);
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: optionText })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Table Panel Controls', () => {
|
||||
test('TC-01 panel name persists and is reflected in the widget header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('table-controls-renamed');
|
||||
await saveWidgetEdit(page);
|
||||
await expect(page.getByTestId('table-controls-renamed').first()).toBeVisible();
|
||||
|
||||
// Server-side check — the PUT must carry the new title.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('table-controls-renamed');
|
||||
|
||||
await openWidgetEditor(page, 'table-controls-renamed');
|
||||
await expect(page.getByTestId('panel-name-input')).toHaveValue(
|
||||
'table-controls-renamed',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-02 description persists and shows info icon on header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('E2E table description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe(
|
||||
'E2E table description',
|
||||
);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page.getByTestId('panel-description-input')).toHaveValue(
|
||||
'E2E table description',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button', { name: /global time/i })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Server-side: persisted timePreferance enum, not just visible label.
|
||||
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(
|
||||
page.locator('section.panel-time-preference').getByRole('button'),
|
||||
).toContainText(/Last 15 min/i);
|
||||
|
||||
await page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /Global Time/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-04 column unit formats the matching column cells and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Use selectColumnUnit to avoid virtualised-list detached-DOM failures.
|
||||
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
|
||||
|
||||
// Wait for the dropdown selection to settle in the editor state
|
||||
// before saving — otherwise the PUT can race the React state update.
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Milliseconds');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Render-side: the cell text should carry the `ms` suffix, confirming
|
||||
// the unit selection reached the formatter. Asserting the *shape* of
|
||||
// the number (digit pattern) belongs to the formatter's unit tests.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toContainText('ms');
|
||||
|
||||
// Server-side: columnUnits must record the unit code, not just the
|
||||
// label. UI display can use a fancy label while the persisted enum drifts.
|
||||
const persistedAfterUnit = await fetchFixtureWidget(page);
|
||||
const columnUnitValues = Object.values(persistedAfterUnit.columnUnits ?? {});
|
||||
expect(columnUnitValues, 'columnUnits must include the chosen unit').toContain(
|
||||
'ms',
|
||||
);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// Section starts collapsed again on re-open — expand before asserting.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Milliseconds');
|
||||
|
||||
// Reset — clear the unit via the Ant Select allowClear X button.
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2')
|
||||
.first()
|
||||
.hover();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
|
||||
.first()
|
||||
.click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-05 decimal precision changes the number of decimals when a column unit is set', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Set a column unit so decimal precision has a visible effect.
|
||||
await selectColumnUnit(page, 'Seconds', 'Seconds (s)');
|
||||
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Wait for both selections to settle in the editor before saving.
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
'0 decimals',
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Seconds');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Render-side: the cell should carry the `s` unit suffix. The exact
|
||||
// numeric formatting (integer vs decimal) is covered by the
|
||||
// formatter's unit tests — keep e2e on the persistence contract.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toContainText('s');
|
||||
|
||||
// Server-side: decimalPrecision must be 0 in the persisted widget.
|
||||
expect((await fetchFixtureWidget(page)).decimalPrecision).toBe(0);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// Section starts collapsed again on re-open — expand before asserting.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
/0 decimals/,
|
||||
);
|
||||
|
||||
// Reset: decimal precision back to 2, clear column unit.
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2')
|
||||
.first()
|
||||
.hover();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
|
||||
.first()
|
||||
.click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-06 column-targeted Background threshold paints only the targeted column', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
const card = page.locator('.threshold-container').first();
|
||||
|
||||
// For TABLE thresholds the column selector (table-operator-input-selector)
|
||||
// defaults to the first aggregation query column (typically `A`). Operator
|
||||
// defaults to '>'; switch to '>=' so it reliably matches non-negative values.
|
||||
await card.getByTestId('operator-input-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await card.getByTestId('threshold-color-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: 'Background' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Save the threshold row (commits it to the thresholds state array).
|
||||
await card.getByRole('button', { name: /save changes/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Inspect the threshold-styled cell directly. The testid host carries
|
||||
// `data-threshold-format="Background"` so we can confirm the format too.
|
||||
const row = page.locator('tr.ant-table-row').first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const styledCell = row.getByTestId('threshold-styled-cell').first();
|
||||
await expect(styledCell).toBeVisible();
|
||||
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Background');
|
||||
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
|
||||
expect(dataStyle).toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] must be persisted with format=Background.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
|
||||
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
|
||||
|
||||
// Reset — delete the threshold via its testid.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// ThresholdsSection defaultOpen is based on threshold count at mount; may
|
||||
// start collapsed due to async state loading — always expand before interacting.
|
||||
await expandSection(page, 'Thresholds');
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-07 column-targeted Text threshold colors only the targeted column text', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
const card = page.locator('.threshold-container').first();
|
||||
|
||||
await card.getByTestId('operator-input-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
// Format defaults to 'Text' — no change needed.
|
||||
await card.getByRole('button', { name: /save changes/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
const row = page.locator('tr.ant-table-row').first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const styledCell = row.getByTestId('threshold-styled-cell').first();
|
||||
await expect(styledCell).toBeVisible();
|
||||
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Text');
|
||||
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
|
||||
expect(dataStyle).toMatch(/color:/);
|
||||
expect(dataStyle).not.toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] must be persisted with format=Text.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
|
||||
|
||||
// Reset
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Thresholds');
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-08 sections hidden for TABLE are not rendered', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
|
||||
await expect(page.locator('section.stack-chart')).toHaveCount(0);
|
||||
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
|
||||
await expect(page.locator('section.log-scale')).toHaveCount(0);
|
||||
await expect(page.locator('section.legend-position')).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-change-select')).toBeVisible();
|
||||
|
||||
// decimal-precision-selector and column-unit-selector are inside the
|
||||
// "Formatting & Units" section which starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
|
||||
await expect(page.locator('.column-unit-selector').first()).toBeVisible();
|
||||
|
||||
// add-threshold-cta is inside "Thresholds" which is also collapsed.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-09 panel type switch from Table to Number persists and re-renders as a number', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await changePanelType(page, 'Number');
|
||||
// Number panel exposes the Y-axis unit selector in the Formatting & Units section.
|
||||
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(page.getByTestId('value-graph-text').first()).toBeVisible();
|
||||
|
||||
// Server-side: persisted panelTypes is the PANEL_TYPES enum value 'value'.
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('value');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page).toHaveURL(/graphType=value/);
|
||||
|
||||
// Reset: switch back to Table.
|
||||
await changePanelType(page, 'Table');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-10 discarding right-pane changes does not persist', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('discard-table-test');
|
||||
|
||||
let putFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
putFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.getByTestId('discard-button').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.last()
|
||||
.getByRole('button', { name: /^OK$/i })
|
||||
.click({ timeout: 1000 })
|
||||
.catch(() => {
|
||||
// no modal — direct navigation
|
||||
});
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
|
||||
|
||||
// Settle before asserting — a delayed PUT could otherwise sneak past.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Apply a combination of edits, save, then hard-reload the page and
|
||||
// re-verify everything renders from the persisted JSON. Catches backend
|
||||
// → frontend rehydration regressions that round-trips via close+reopen
|
||||
// editor miss (re-opening the editor reuses the in-memory query state).
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
|
||||
// Wait for the column-unit dropdown to settle before saving so the
|
||||
// PUT carries the new unit (Ant Select onChange is async via React).
|
||||
await expect(
|
||||
page.getByTestId(/^column-unit-row-/).first(),
|
||||
).toContainText('Milliseconds');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Hard reload — purges in-memory state, forces a fresh fetch.
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Cell value must still carry the unit after reload (proves the
|
||||
// columnUnits + decimalPrecision + panelType rehydrated correctly).
|
||||
// Numeric-shape validation lives in the formatter's unit tests.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toContainText('ms');
|
||||
|
||||
// Description info icon (the only header surface for description) must
|
||||
// still render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset: clear unit + description.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2')
|
||||
.first()
|
||||
.hover();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
|
||||
.first()
|
||||
.click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
589
tests/e2e/tests/dashboards/panels/value.spec.ts
Normal file
589
tests/e2e/tests/dashboards/panels/value.spec.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
openWidgetEditor,
|
||||
saveWidgetEdit,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// All TCs operate on the same fixture panel and toggle its state — they MUST
|
||||
// run serially within the worker. Project-level fullyParallel still runs this
|
||||
// file in parallel with other files.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const FIXTURE_DASHBOARD_TITLE = 'value-controls-fixture';
|
||||
const FIXTURE_PANEL_TITLE = 'value-controls-panel';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
|
||||
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
|
||||
// configureAndSavePanel creates a Time Series panel. Switch it to the
|
||||
// Number (VALUE) type before the per-TC bodies run.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await changePanelType(page, 'Number');
|
||||
await saveWidgetEdit(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoFixtureDashboard(page: Page): Promise<void> {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/** Fetch the persisted fixture dashboard's first widget. */
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a SettingsSection accordion in the widget editor right pane is
|
||||
* expanded. If it is already open (content div has the `open` class), this is
|
||||
* a no-op. Otherwise it clicks the header button and waits for the CSS
|
||||
* transition to complete. This handles both the common case (collapsed on
|
||||
* mount) and the defensive case (already open).
|
||||
*/
|
||||
async function expandSection(page: Page, title: string): Promise<void> {
|
||||
// Find the settings-section that contains this title in its header.
|
||||
const section = page
|
||||
.locator('.settings-section')
|
||||
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
|
||||
|
||||
// Check if the content div already has the `open` class.
|
||||
const contentDiv = section.locator('.settings-section-content');
|
||||
const isOpen = await contentDiv.evaluate((el) =>
|
||||
el.classList.contains('open'),
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
// Click the header button to open the section.
|
||||
await section.locator('button.settings-section-header').click();
|
||||
// Wait for the CSS transition to complete (opacity 0→1, max-height 0→1000px).
|
||||
await contentDiv.waitFor({ state: 'visible' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a unit from the Y-axis unit selector dropdown by typing a search
|
||||
* term, then clicking the filtered option. The selector has `showSearch`
|
||||
* enabled and renders a long virtualised option list — typing first avoids
|
||||
* instability from the virtualised list re-rendering when the target option
|
||||
* is off-screen.
|
||||
*/
|
||||
async function selectYAxisUnit(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionText: string,
|
||||
): Promise<void> {
|
||||
// Click the outer wrapper to open the dropdown.
|
||||
const unitSelect = page.locator('.y-axis-unit-selector-v2 .ant-select').first();
|
||||
await unitSelect.click();
|
||||
// The Ant Select input is now focused — type to filter the virtual list.
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select input').first().fill(searchTerm);
|
||||
// Wait for the dropdown to show the filtered option, then click it.
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: optionText })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Value Panel Controls', () => {
|
||||
test('TC-01 panel name persists and is reflected in the widget header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('value-controls-renamed');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(page.getByTestId('value-controls-renamed').first()).toBeVisible();
|
||||
|
||||
// Server-side check.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('value-controls-renamed');
|
||||
|
||||
await openWidgetEditor(page, 'value-controls-renamed');
|
||||
await expect(page.getByTestId('panel-name-input')).toHaveValue(
|
||||
'value-controls-renamed',
|
||||
);
|
||||
|
||||
// Reset back to fixture title so subsequent TCs locate the panel.
|
||||
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-02 panel description persists and renders the info icon on the header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('E2E test description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe(
|
||||
'E2E test description',
|
||||
);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page.getByTestId('panel-description-input')).toHaveValue(
|
||||
'E2E test description',
|
||||
);
|
||||
|
||||
// Reset
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-03 panel time preference switches from Global Time to Last 15 min and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
const timeButton = page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button', { name: /global time/i });
|
||||
await timeButton.click();
|
||||
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
|
||||
await expect(
|
||||
page.locator('section.panel-time-preference').getByRole('button'),
|
||||
).toContainText(/Last 15 min/i);
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Server-side: persisted timePreferance enum.
|
||||
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(
|
||||
page.locator('section.panel-time-preference').getByRole('button'),
|
||||
).toContainText(/Last 15 min/i);
|
||||
|
||||
// Reset
|
||||
await page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /Global Time/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-04 Y-axis unit applies a suffix to the rendered value and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// The Y-Axis Unit selector has showSearch enabled and a long virtualised
|
||||
// option list. Type "Seconds" to filter before clicking.
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
|
||||
// Live preview should now render a suffix unit `s`.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Back on the dashboard the panel card should also render the suffix.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Server-side: yAxisUnit must hold the unit code (catches a label-only
|
||||
// regression where the UI shows "Seconds" but persists nothing).
|
||||
expect((await fetchFixtureWidget(page)).yAxisUnit).toBe('s');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(
|
||||
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
|
||||
).toContainText(/Seconds/);
|
||||
|
||||
// Reset — clear the unit via allowClear (X button on the Ant Select).
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-05 decimal precision reformats the rendered value when a unit is set', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Setting a unit is required for decimal precision to have a visible
|
||||
// effect — see Known Limitations #3 in the test plan.
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Wait for the dropdown to reflect the chosen precision before saving
|
||||
// so the editor's state has actually applied the change.
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
'0 decimals',
|
||||
);
|
||||
|
||||
// Reformatting check: with decimalPrecision=0 the rendered value must
|
||||
// not contain a decimal separator. The exact numeric value depends on
|
||||
// query/seed alignment and lives in the formatter's unit tests; this
|
||||
// asserts only that the precision setting reaches the render layer.
|
||||
const valueText = page.getByTestId('value-graph-text').first();
|
||||
await expect(valueText).toBeVisible();
|
||||
await expect(valueText).not.toContainText('.');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Same assertion post-save: the dashboard render must respect the
|
||||
// persisted precision, not just the editor's live preview.
|
||||
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
|
||||
'.',
|
||||
);
|
||||
|
||||
// Server-side: decimalPrecision is 0 and yAxisUnit is 's'.
|
||||
const persistedDecimals = await fetchFixtureWidget(page);
|
||||
expect(persistedDecimals.decimalPrecision).toBe(0);
|
||||
expect(persistedDecimals.yAxisUnit).toBe('s');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
/0 decimals/,
|
||||
);
|
||||
|
||||
// Reset: restore default 2 decimals and clear the unit.
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-06 Text-format threshold colors the rendered value text and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds
|
||||
// (defaultOpen={!!thresholds.length}) — expand it first.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
|
||||
// VALUE panels do not render a threshold label input — only operator,
|
||||
// value, unit, format (Text/Background), and color. Defaults: operator
|
||||
// '>', format 'Text', value 0, color 'Red'. We force operator to '>=' so
|
||||
// the threshold reliably matches non-negative values.
|
||||
const thresholdCard = page.locator('.threshold-container').first();
|
||||
await thresholdCard
|
||||
.getByTestId('operator-input-selector')
|
||||
.click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Save the threshold row (commits it to the thresholds state array). The
|
||||
// dashboard PUT still needs `saveWidgetEdit` after this.
|
||||
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Dashboard render: value text should now carry an inline color style.
|
||||
const valueText = page.getByTestId('value-graph-text').first();
|
||||
await expect(valueText).toBeVisible();
|
||||
const inlineStyle = await valueText.getAttribute('style');
|
||||
expect(inlineStyle).toMatch(/color:/);
|
||||
|
||||
// Server-side: thresholds[] persisted with format=Text.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
|
||||
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
|
||||
|
||||
// Re-open editor and verify the threshold round-tripped.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// The ThresholdsSection defaultOpen is based on threshold count at mount
|
||||
// time; due to async state loading it may start collapsed. Expand it.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await expect(
|
||||
page.locator('.threshold-container').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset — delete the threshold via testid.
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-07 Background-format threshold paints the value container background', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
const thresholdCard = page.locator('.threshold-container').first();
|
||||
|
||||
// Set operator >= and switch format from Text to Background.
|
||||
await thresholdCard.getByTestId('operator-input-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await thresholdCard.getByTestId('threshold-color-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: 'Background' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Dashboard render: value-graph-container must carry an inline background.
|
||||
const container = page.getByTestId('value-graph-container').first();
|
||||
await expect(container).toBeVisible();
|
||||
const inlineStyle = await container.getAttribute('style');
|
||||
expect(inlineStyle).toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] persisted with format=Background.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
|
||||
|
||||
// Reset
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// ThresholdsSection may start collapsed even with thresholds — always
|
||||
// expand before interacting with threshold cards.
|
||||
await expandSection(page, 'Thresholds');
|
||||
// Edit/delete buttons are display:none by default, revealed on :hover.
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-08 clearing the Y-axis unit removes the suffix from the rendered value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Apply a unit first.
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
await saveWidgetEdit(page);
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Clear it.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Suffix should be gone from the rendered panel.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit')).toHaveCount(0);
|
||||
|
||||
// Server-side: yAxisUnit must be cleared (empty / undefined).
|
||||
const cleared = await fetchFixtureWidget(page);
|
||||
expect(cleared.yAxisUnit ?? '').toBe('');
|
||||
});
|
||||
|
||||
test('TC-09 panel type switch from Number to Time Series persists and re-renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await changePanelType(page, 'Time Series');
|
||||
// Time Series exposes Fill gaps — confirm the right pane re-rendered.
|
||||
await expect(page.locator('section.fill-gaps')).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Server-side: panelTypes is 'graph' (PANEL_TYPES.TIME_SERIES).
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('graph');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page).toHaveURL(/graphType=graph/);
|
||||
|
||||
// Reset: switch back to Number for downstream TCs.
|
||||
await changePanelType(page, 'Number');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-10 sections hidden for VALUE are not rendered in the right pane', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// Hidden by the panel-type matrix for VALUE — these sections are not
|
||||
// rendered in the DOM at all (conditionally excluded by RightContainer).
|
||||
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
|
||||
await expect(page.locator('section.log-scale')).toHaveCount(0);
|
||||
await expect(page.locator('section.legend-position')).toHaveCount(0);
|
||||
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
|
||||
await expect(page.locator('section.stack-chart')).toHaveCount(0);
|
||||
|
||||
// Expected to be present in the always-open General and Visualization
|
||||
// sections.
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-change-select')).toBeVisible();
|
||||
|
||||
// The "Formatting & Units" section is collapsed on open — expand it to
|
||||
// verify the controls are rendered for VALUE.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
|
||||
|
||||
// The "Thresholds" section is collapsed when there are no thresholds —
|
||||
// expand it to verify the Add Threshold CTA is rendered for VALUE.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 discarding right-pane changes does not persist or visually update', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('discard-value-test');
|
||||
|
||||
let putFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
putFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.getByTestId('discard-button').click();
|
||||
// If a discard confirmation appears, OK it. Right-pane-only changes
|
||||
// usually don't trigger one.
|
||||
const confirmDialog = page.getByRole('dialog').last();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: /^OK$/i })
|
||||
.click({ timeout: 1000 })
|
||||
.catch(() => {
|
||||
// no modal — the editor navigated away immediately
|
||||
});
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
|
||||
|
||||
// Settle before asserting no PUT.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-12 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Apply unit + description + decimal precision, save, hard-reload, and
|
||||
// re-verify the panel renders correctly from the persisted JSON.
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Suffix unit must render after rehydration.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Description info icon must render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user