Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi Kumar
fea77fa345 test(dashboard-v2): e2e for editor sections, thresholds and list columns
Covers the per-kind option sections of the editor: which sections each
visualisation declares, and whether the values they hold reach the chart and
survive a save.

- sections are asserted per kind, not once. A section that a kind must not
  offer is as much a requirement as one it must: Fill gaps on TimeSeries but
  not Bar, Stack series on Bar but not TimeSeries, per-column units on Table
  instead of a panel-wide one, no axes on Number or Pie.
- axis bounds, log scale and chart appearance are read back off the live uPlot
  instance, so a control that writes the spec but never reaches the chart
  fails. A cleared bound is checked to store null rather than zero.
- thresholds cover all three variants (label, comparison, table) plus the
  editing rules — one row editable at a time, discard removing a new row but
  restoring an existing one — and then that a crossed threshold actually
  colours the rendered value, live and after a save.
- context links and the List columns editor cover their dialogs, persistence
  into panel.spec, and the collapsed-section quick-add path.

Adds per-name test ids to the List column chips and field suggestions so a
specific column can be targeted; no behaviour change.

Specs: sections (TimeSeries/Bar), sections (Number/Table/Pie/Histogram),
thresholds, context links, list columns (51 tests).
2026-08-05 18:36:10 +05:30
Abhi Kumar
35888d0006 test(dashboard-v2): e2e for the panel editor shell, creation and query pane
Covers the editor's lifecycle rather than its option sections: creating a panel
from the New Panel modal into a chosen section, the dirty/save/close contract,
the query pane, switching visualisation kind mid-edit, and the capability
matrix that decides which kinds and query types are offered together.

Two areas worth a reviewer's attention:

- type switching is reversible through a per-kind session cache, so the specs
  assert both directions and that the cache does not survive a reload. They
  also pin the migrations a switch performs: a panel-wide unit fanning out to
  per-column units on Table, custom legend colours dropped on a first visit,
  axis bounds kept when both kinds declare them.
- the capability specs assert the disabled states the editor renders AND that
  the backend rejects the same combination, so a drift between the two shows up
  as a failure rather than as a silently permissive UI.

Test-only; no source changes.

Specs: creation, editor shell, query pane, type switch, capabilities
(46 tests).
2026-08-05 18:36:10 +05:30
12 changed files with 2349 additions and 3 deletions

View File

@@ -87,7 +87,7 @@ function AddColumnDropdown({
value={field.name}
isSelected={selectedNames.has(field.name)}
onSelect={(): void => onToggle(field)}
data-testid="list-columns-suggestion"
data-testid={`list-columns-suggestion-${field.name}`}
>
{field.name}
</ComboboxItem>

View File

@@ -35,7 +35,12 @@ function SortableColumnChip({
};
return (
<div ref={setNodeRef} style={style} className={styles.chip}>
<div
ref={setNodeRef}
style={style}
className={styles.chip}
data-testid={`list-column-chip-${name}`}
>
<Button
type="button"
variant="ghost"
@@ -58,7 +63,7 @@ function SortableColumnChip({
size="icon"
className={styles.remove}
aria-label={`Remove ${name}`}
testId="list-column-remove"
testId={`list-column-remove-${name}`}
onClick={(): void => onRemove(name)}
>
<X size={12} />

View File

@@ -0,0 +1,225 @@
import { expect, test } from '../../../../fixtures/dashboards';
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
import {
createDashboardV2ViaApi,
getDashboardV2ViaApi,
gotoEmptyDashboardV2,
} from '../../../../helpers/dashboards-v2';
import {
capturePatchOps,
editor,
savePanel,
} from '../../../../helpers/panel-editor-v2';
import { panelRoot } from '../../../../helpers/panels-v2';
import {
emptyDashboard,
singlePanelDashboard,
compactDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: creating a panel — the modal's two branches, the route it hands off
// to, and the JSON-Patch a save emits.
//
// The subtlety: one section means a tile click creates immediately; several
// means select-then-confirm. Backwards, and the user is stranded on a dialog.
const ALL_TILES: [PanelKind, string][] = [
[PanelKind.TimeSeries, 'Time Series'],
[PanelKind.Number, 'Number'],
[PanelKind.Table, 'Table'],
[PanelKind.BarChart, 'Bar Chart'],
[PanelKind.PieChart, 'Pie Chart'],
[PanelKind.Histogram, 'Histogram'],
[PanelKind.List, 'List'],
];
test.describe('Dashboards V2 — panel creation', () => {
test('TC-01 the New Panel modal lists every panel kind', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seed(emptyDashboard());
await gotoEmptyDashboardV2(page, id);
await page.getByTestId('add-panel').click();
const dialog = page.getByRole('dialog', { name: 'New Panel' });
await expect(dialog).toBeVisible();
for (const [kind, label] of ALL_TILES) {
const tile = page.getByTestId(`panel-type-${kind}`);
await expect(tile).toBeVisible();
await expect(tile).toContainText(label);
}
});
test('TC-02 with one section a tile click creates immediately', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await page.getByTestId('add-panel-header').click();
await expect(page.getByRole('dialog', { name: 'New Panel' })).toBeVisible();
// One section: no footer, no picker, no confirm.
await expect(page.getByTestId('panel-type-confirm')).toHaveCount(0);
await page.getByTestId(`panel-type-${PanelKind.Table}`).click();
await page.waitForURL(/\/panel\/new\?/);
const params = new URL(page.url()).searchParams;
expect(params.get('panelKind')).toBe(PanelKind.Table);
await expect(editor.root(page)).toBeVisible();
});
test('TC-03 with several sections the modal requires an explicit confirm', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await page.getByTestId('add-panel-header').click();
await expect(page.getByRole('dialog', { name: 'New Panel' })).toBeVisible();
const confirm = page.getByTestId('panel-type-confirm');
await expect(confirm).toBeVisible();
await expect(confirm).toBeDisabled();
await expect(page.getByTestId('panel-section-select')).toBeVisible();
await page.getByTestId(`panel-type-${PanelKind.Number}`).click();
await expect(confirm).toBeEnabled();
await confirm.click();
await page.waitForURL(/\/panel\/new\?/);
expect(new URL(page.url()).searchParams.get('panelKind')).toBe(
PanelKind.Number,
);
});
test('TC-04 the chosen section becomes the new panel layoutIndex', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await page.getByTestId('add-panel-header').click();
await page.getByTestId(`panel-type-${PanelKind.Number}`).click();
await page.getByTestId('panel-section-select').click();
await page.getByTestId('panel-section-option-1').click();
await page.getByTestId('panel-type-confirm').click();
await page.waitForURL(/\/panel\/new\?/);
expect(new URL(page.url()).searchParams.get('layoutIndex')).toBe('1');
});
test('TC-05 creating from an empty section targets that section', async ({
authedPage: page,
dashboards,
}) => {
const dashboard = compactDashboard();
dashboard.spec.layouts.push({
kind: 'Grid',
spec: { display: { title: 'Empty' }, items: [] },
});
await dashboards.seedAndOpen(dashboard);
const sectionCta = page.locator('[data-testid^="section-add-panel-"]');
await sectionCta.first().scrollIntoViewIfNeeded();
await sectionCta.first().click();
await expect(page.getByRole('dialog', { name: 'New Panel' })).toBeVisible();
await page.getByTestId(`panel-type-${PanelKind.TimeSeries}`).click();
await page.getByTestId('panel-type-confirm').click();
await page.waitForURL(/\/panel\/new\?/);
expect(new URL(page.url()).searchParams.get('layoutIndex')).toBe('2');
});
test('TC-06 saving a new panel emits add-panel and add-layout-item ops', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
const patches = capturePatchOps(page);
await page.getByTestId('add-panel-header').click();
// List: its seeded query passes validation (see TC-07).
await page.getByTestId(`panel-type-${PanelKind.List}`).click();
await expect(editor.root(page)).toBeVisible();
await editor.title(page).fill('Created from the modal');
await savePanel(page);
// Targeted adds for panel AND grid item — a replace would clobber edits.
expect(patches.length).toBeGreaterThan(0);
const ops = patches[patches.length - 1];
const panelAdd = ops.find((op) => /^\/spec\/panels\/[^/]+$/.test(op.path));
expect(panelAdd?.op).toBe('add');
expect(
ops.some((op) => /^\/spec\/layouts\/\d+\/spec\/items\/-$/.test(op.path)),
).toBe(true);
});
test('TC-07 the created panel lands on the dashboard and persists', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
const before = await getDashboardV2ViaApi(page, id);
const beforeCount = Object.keys(before.spec.panels).length;
await page.getByTestId('add-panel-header').click();
// List is the one kind whose seeded query saves as-is (logs `count()`);
// metrics kinds are rejected until a metric is chosen — see TC-09.
await page.getByTestId(`panel-type-${PanelKind.List}`).click();
await expect(editor.root(page)).toBeVisible();
await editor.title(page).fill('Fresh list panel');
await savePanel(page);
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
await expect(
page.getByTestId('panel-title').filter({ hasText: 'Fresh list panel' }),
).toBeVisible();
const after = await getDashboardV2ViaApi(page, id);
expect(Object.keys(after.spec.panels)).toHaveLength(beforeCount + 1);
});
test('TC-09 saving a metrics panel with no metric chosen is rejected', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await page.getByTestId('add-panel-header').click();
await page.getByTestId(`panel-type-${PanelKind.Number}`).click();
await expect(editor.root(page)).toBeVisible();
// Metrics kinds seed an empty aggregation, so the editor must stay open and
// surface the reason rather than dropping the panel.
const rejected = page.waitForResponse(
(r) =>
r.request().method() === 'PATCH' && /\/api\/v2\/dashboards\//.test(r.url()),
);
await editor.save(page).click();
const response = await rejected;
expect(response.status()).toBe(400);
expect(await response.text()).toContain('metric name is required');
await expect(editor.root(page)).toBeVisible();
});
test('TC-08 the editor route redirects when panelKind is missing', async ({
authedPage: page,
}) => {
const id = await createDashboardV2ViaApi(page, singlePanelDashboard());
// No kind to seed, so the page bounces back.
await page.goto(`/dashboard/${id}/panel/new`);
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
await expect(panelRoot(page, 'solo-panel')).toBeVisible();
});
});

View File

@@ -0,0 +1,201 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
getDashboardV2ViaApi,
gotoPanelEditor,
setDashboardLockedViaApi,
} from '../../../../helpers/dashboards-v2';
import {
EditorText,
capturePatchOps,
closeEditor,
editor,
savePanel,
} from '../../../../helpers/panel-editor-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the editor shell — editing, dirty badge, save, discard guard, locking.
//
// Two counter-intuitive behaviours are pinned so a "cleanup" doesn't change
// them: Save is NOT gated on dirty state (TC-03), and only the in-app close
// button guards unsaved edits — there is no beforeunload blocker (TC-07).
test.describe('Dashboards V2 — panel editor shell', () => {
test('TC-01 the editor opens on the panel with its saved title', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(page.getByText(EditorText.title)).toBeVisible();
await expect(editor.title(page)).toHaveValue('Solo panel');
await expect(page.getByTestId('preview-pane')).toBeVisible();
await expect(editor.queryBuilder(page)).toBeVisible();
});
test('TC-02 editing the title marks the editor dirty', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.unsavedBadge(page)).toHaveCount(0);
await editor.title(page).fill('Renamed panel');
await expect(editor.unsavedBadge(page)).toBeVisible();
});
test('TC-03 Save stays enabled on a pristine panel', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
// Gated on editability, not isDirty.
await expect(editor.unsavedBadge(page)).toHaveCount(0);
await expect(editor.save(page)).toBeEnabled();
});
test('TC-04 saving persists the title and returns to the dashboard', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
const patches = capturePatchOps(page);
await editor.title(page).fill('Renamed via editor');
await editor.description(page).fill('Edited in the E2E suite');
await savePanel(page);
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Renamed via editor',
);
// A single targeted add at the panel's spec pointer.
const ops = patches[patches.length - 1];
expect(ops).toHaveLength(1);
expect(ops[0].path).toBe(`/spec/panels/${SINGLE_PANEL_ID}/spec`);
expect(ops[0].op).toBe('add');
});
test('TC-05 closing a pristine editor leaves immediately', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await closeEditor(page);
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
await expect(page.getByTestId('panel-editor-v2-discard-modal')).toHaveCount(
0,
);
});
test('TC-06 closing a dirty editor asks before discarding', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.title(page).fill('Throwaway edit');
await expect(editor.unsavedBadge(page)).toBeVisible();
// Keep editing leaves the edit intact.
await closeEditor(page, { expectDirty: true, keepEditing: true });
await expect(editor.root(page)).toBeVisible();
await expect(editor.title(page)).toHaveValue('Throwaway edit');
await closeEditor(page, { expectDirty: true });
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Solo panel',
);
});
test('TC-07 navigating away by URL loses edits without a prompt', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.title(page).fill('Never saved');
// Known behaviour, not endorsed: the guard is on the close button only. If
// a router blocker is added, update this test rather than deleting it.
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('panel-editor-v2-discard-modal')).toHaveCount(
0,
);
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Solo panel',
);
});
test('TC-08 a locked dashboard disables Save and never PATCHes', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seed(singlePanelDashboard());
await setDashboardLockedViaApi(page, id, true);
const patches = capturePatchOps(page);
await gotoPanelEditor(page, id, SINGLE_PANEL_ID);
const save = editor.save(page);
await expect(save).toBeDisabled();
// A disabled button swallows pointer events; hover the wrapping trigger.
await page
.locator('[data-slot="tooltip-trigger"]')
.filter({ has: save })
.hover();
await expect(page.getByText(EditorText.lockedReason)).toBeVisible();
// The store short-circuits a locked patch before the network.
expect(patches).toHaveLength(0);
});
test('TC-09 an unknown panel id redirects back to the dashboard', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seed(singlePanelDashboard());
await page.goto(`/dashboard/${id}/panel/does-not-exist`);
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
await expect(editor.root(page)).toHaveCount(0);
});
test('TC-10 Switch to View Mode hands off to the View modal', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.switchToView(page).click();
await page.waitForURL(new RegExp(`/dashboard/${id}\\?`));
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
});
});

View File

@@ -0,0 +1,182 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
GOLDEN,
PanelKind,
logsCountQuery,
} from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
EditorText,
QueryTab,
editor,
queryTab,
runQuery,
savePanel,
selectMetric,
} from '../../../../helpers/panel-editor-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the query pane and its commit semantics.
//
// The rule worth pinning: editing the builder does NOT move the preview (only
// Run, or a structural change, commits into the draft) — but Save serialises
// the LIVE query anyway, so an unrun edit still persists. Contradictory-looking
// and easy to "fix" into a regression.
test.describe('Dashboards V2 — panel editor query pane', () => {
test('TC-01 the builder offers every query type the kind supports', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
await expect(queryTab(page, QueryTab.clickhouse)).toBeVisible();
await expect(queryTab(page, QueryTab.promql)).toBeVisible();
});
test('TC-02 a List panel offers only the Query Builder tab', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.List }),
SINGLE_PANEL_ID,
);
// Hidden here, not disabled — the switcher is the one that disables.
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
await expect(queryTab(page, QueryTab.clickhouse)).toHaveCount(0);
await expect(queryTab(page, QueryTab.promql)).toHaveCount(0);
});
test('TC-03 Run Query issues a fresh query_range', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
await runQuery(page);
});
test('TC-04 the run keyboard shortcut works from inside the builder', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
// Bound with onKeyDownCapture, so it fires from inside inputs too.
const response = page.waitForResponse((r) =>
r.url().includes('/query_range'),
);
await editor.queryBuilder(page).click();
await page.keyboard.press('ControlOrMeta+Enter');
await response;
});
test('TC-05 switching query type re-renders the pane and marks the panel dirty', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.unsavedBadge(page)).toHaveCount(0);
await queryTab(page, QueryTab.promql).click();
await expect(queryTab(page, QueryTab.promql)).toHaveAttribute(
'aria-selected',
'true',
);
// Structural change: auto-commits without Run.
await expect(editor.unsavedBadge(page)).toBeVisible();
});
test('TC-06 choosing a metric makes a new panel savable', async ({
authedPage: page,
dashboards,
}) => {
// create → configure → save. A new metrics panel seeds an empty
// aggregation and is rejected until a metric is picked (03-creation TC-09).
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await page.getByTestId('add-panel-header').click();
await page.getByTestId(`panel-type-${PanelKind.TimeSeries}`).click();
await expect(editor.root(page)).toBeVisible();
await selectMetric(page, GOLDEN.metrics.calls);
await editor.title(page).fill('Configured then saved');
await savePanel(page);
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
const after = await getDashboardV2ViaApi(page, id);
const saved = Object.values(after.spec.panels).find(
(candidate) => candidate.spec.display.name === 'Configured then saved',
);
expect(saved).toBeDefined();
expect(JSON.stringify(saved?.spec.queries)).toContain(GOLDEN.metrics.calls);
});
test('TC-07 an unrun query edit is still persisted by Save', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
// No Run: `buildSaveSpec` serialises the live query, so the edit must
// survive — otherwise saving without running loses work.
await selectMetric(page, GOLDEN.metrics.latencyCount);
await savePanel(page);
await expect
.poll(async () => {
const after = await getDashboardV2ViaApi(page, id);
return JSON.stringify(
after.spec.panels[SINGLE_PANEL_ID].spec.queries,
).includes(GOLDEN.metrics.latencyCount);
})
.toBe(true);
});
test('TC-08 the in-editor query survives a reload', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await selectMetric(page, GOLDEN.metrics.latencySum);
await runQuery(page);
// No forceReset, so the URL query wins and survives a refresh.
await page.reload();
await expect(editor.root(page)).toBeVisible();
await expect(
page.getByTestId('metric-name-selector-0').locator('input'),
).toHaveValue(GOLDEN.metrics.latencySum);
});
test('TC-09 a logs panel runs without needing a metric', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
// Logs aggregate by expression, so there's no metric to fill.
await expect(
page.getByRole('button', { name: EditorText.runQuery }),
).toBeVisible();
await runQuery(page);
await savePanel(page);
});
});

View File

@@ -0,0 +1,198 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../../fixtures/dashboards';
import {
PanelKind,
logsCountQuery,
} from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
Section,
editor,
expandSection,
savePanel,
selectOption,
} from '../../../../helpers/panel-editor-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: switching kind mid-edit — what the session cache restores, and which
// config survives a first-visit transfer. Config only carries when the TARGET
// kind declares that control (buildPluginSpec).
async function switchKind(page: Page, label: string): Promise<void> {
await selectOption(page, 'panel-editor-v2-type-switcher', label);
}
test.describe('Dashboards V2 — panel type switching', () => {
test('TC-01 switching kind re-renders the preview', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
await switchKind(page, 'Table');
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
await expect(page.getByTestId('time-series-renderer')).toHaveCount(0);
});
test('TC-02 switching marks the editor dirty but does not persist', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await switchKind(page, 'Number');
await expect(editor.unsavedBadge(page)).toBeVisible();
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
PanelKind.TimeSeries,
);
});
test('TC-03 switching back restores the original kind from the session cache', async ({
authedPage: page,
dashboards,
}) => {
// Logs, because List rejects metrics; time_series-shaped, because Table
// can't read `raw`.
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table, query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
await switchKind(page, 'List');
await expect(page.getByTestId('list-panel-renderer')).toBeVisible();
// The per-kind cache makes the round trip reversible.
await switchKind(page, 'Table');
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
});
test('TC-04 the session cache does not survive a reload', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await switchKind(page, 'Table');
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
// The cache is a ref on the mounted editor, so a reload drops it. Reload
// alone — a redundant goto afterwards crashed the page on WebKit.
await page.reload();
await expect(editor.root(page)).toBeVisible();
await expect(page.getByTestId('time-series-renderer')).toBeVisible();
});
test('TC-05 a saved switch persists the new kind', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await switchKind(page, 'Bar Chart');
await savePanel(page);
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
PanelKind.BarChart,
);
});
test('TC-06 sections follow the target kind', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
// Time Series declares Axes and Chart Appearance.
await expect(
page.getByTestId('config-section-chart-appearance'),
).toBeVisible();
await switchKind(page, 'Number');
// Number declares neither, but keeps Formatting.
await expect(page.getByTestId('config-section-chart-appearance')).toHaveCount(
0,
);
await expect(page.getByTestId('config-section-axes')).toHaveCount(0);
await expect(
page.getByTestId('config-section-formatting-&-units'),
).toBeVisible();
});
test('TC-07 a panel-wide unit fans out into per-column units on Table', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ pluginSpec: { formatting: { unit: 'ms' } } }),
SINGLE_PANEL_ID,
);
await switchKind(page, 'Table');
await savePanel(page);
// Table has no panel-wide unit, so it fans out to columns — one-way.
const after = await getDashboardV2ViaApi(page, id);
const formatting =
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.formatting;
expect(formatting?.unit).toBeUndefined();
expect(Object.values(formatting?.columnUnits ?? {})).toContain('ms');
});
test('TC-08 custom legend colours are dropped on a first-visit switch', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({
pluginSpec: {
legend: { position: 'bottom', customColors: { adservice: '#ff0000' } },
},
}),
SINGLE_PANEL_ID,
);
await switchKind(page, 'Bar Chart');
await savePanel(page);
// Keyed by series label, which the new kind may not reproduce, so dropped.
// Round-trips as null rather than being omitted.
const after = await getDashboardV2ViaApi(page, id);
const { customColors } =
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.legend ?? {};
expect(customColors ?? undefined).toBeUndefined();
});
test('TC-09 axis bounds survive a switch between kinds that both declare them', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.axes);
await page.getByTestId('panel-editor-v2-soft-min').fill('5');
await page.getByTestId('panel-editor-v2-soft-max').fill('50');
// Bar Chart also declares minMax, so the bounds must carry over.
await switchKind(page, 'Bar Chart');
await savePanel(page);
const after = await getDashboardV2ViaApi(page, id);
const axes = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.axes;
expect(axes?.softMin).toBe(5);
expect(axes?.softMax).toBe(50);
});
});

View File

@@ -0,0 +1,209 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
GOLDEN,
PanelKind,
clickhouseQuery,
logsCountQuery,
metricsQuery,
promqlQuery,
rawQuery,
} from '../../../../helpers/dashboard-v2-spec';
import {
QueryTab,
queryTab,
selectOptions,
} from '../../../../helpers/panel-editor-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the panelKind × queryType × signal matrix, as the editor surfaces it.
//
// Two treatments, and mixing them up is the bug this guards: the type SWITCHER
// disables unsupported kinds (with a reason); the query TABS omit them.
/** The switcher's option labels paired with whether they're selectable. */
async function switcherOptions(
page: Parameters<typeof selectOptions>[0],
): Promise<{ label: string; disabled: boolean }[]> {
return selectOptions(page, 'panel-editor-v2-type-switcher');
}
function optionFor(
options: { label: string; disabled: boolean }[],
label: string,
): { label: string; disabled: boolean } {
const match = options.find((option) => option.label.startsWith(label));
expect(
match,
`expected a "${label}" option in the type switcher`,
).toBeDefined();
return match as { label: string; disabled: boolean };
}
test.describe('Dashboards V2 — editor capabilities matrix', () => {
test('TC-01 every kind is selectable for a metrics builder query', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
const options = await switcherOptions(page);
// List is the exception: logs/traces only.
for (const label of [
'Time Series',
'Number',
'Table',
'Bar Chart',
'Pie Chart',
'Histogram',
]) {
expect(optionFor(options, label).disabled).toBe(false);
}
});
test('TC-02 List is disabled for a metrics query', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
const options = await switcherOptions(page);
expect(optionFor(options, 'List').disabled).toBe(true);
});
test('TC-03 List becomes selectable for a logs query', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
const options = await switcherOptions(page);
expect(optionFor(options, 'List').disabled).toBe(false);
});
test('TC-04 a PromQL panel disables the kinds that cannot read PromQL', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
}),
SINGLE_PANEL_ID,
);
const options = await switcherOptions(page);
// Pie, Table and List omit PromQL.
expect(optionFor(options, 'Pie Chart').disabled).toBe(true);
expect(optionFor(options, 'Table').disabled).toBe(true);
expect(optionFor(options, 'List').disabled).toBe(true);
expect(optionFor(options, 'Time Series').disabled).toBe(false);
expect(optionFor(options, 'Bar Chart').disabled).toBe(false);
});
test('TC-05 a disabled option explains itself in a tooltip', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await page.getByTestId('panel-editor-v2-type-switcher').click();
const dropdown = page.locator(
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
);
await dropdown
.locator('.ant-select-item-option-disabled')
.filter({ hasText: 'List' })
.first()
.hover();
// The wording is the contract users read.
await expect(
page.getByText("List doesn't support metrics data"),
).toBeVisible();
});
test('TC-06 a ClickHouse panel keeps Table selectable but not List', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: PanelKind.Table,
query: clickhouseQuery(
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
),
}),
SINGLE_PANEL_ID,
);
const options = await switcherOptions(page);
expect(optionFor(options, 'Table').disabled).toBe(false);
expect(optionFor(options, 'List').disabled).toBe(true);
});
test('TC-07 a List panel hides the query types it cannot use', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: PanelKind.List,
query: rawQuery({ signal: 'logs' }),
}),
SINGLE_PANEL_ID,
);
// Hidden, not disabled.
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
await expect(queryTab(page, QueryTab.clickhouse)).toHaveCount(0);
await expect(queryTab(page, QueryTab.promql)).toHaveCount(0);
});
test('TC-08 a Table panel offers ClickHouse but not PromQL', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expect(queryTab(page, QueryTab.builder)).toBeVisible();
await expect(queryTab(page, QueryTab.clickhouse)).toBeVisible();
await expect(queryTab(page, QueryTab.promql)).toHaveCount(0);
});
test('TC-09 the backend rejects a combination the editor disables', async ({
dashboards,
}) => {
// Enforced server-side too, so an invalid pairing can never be persisted.
// Pins the layers together: if capabilities.ts and allowedQueryKinds
// drift, either TC-04 or this fails.
await expect(
dashboards.seed(
singlePanelDashboard({
kind: PanelKind.PieChart,
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
}),
),
// Quotes in the message are JSON-escaped, so match around them.
).rejects.toThrow(/PromQLQuery.*not supported by panel kind.*PieChartPanel/);
});
});

View File

@@ -0,0 +1,300 @@
import { expect, test } from '../../../../fixtures/dashboards';
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
Section,
editor,
expandSection,
savePanel,
sectionToggle,
selectOption,
setSegment,
} from '../../../../helpers/panel-editor-v2';
import {
QueryRange,
mockQueryRange,
ramp,
} from '../../../../helpers/query-range-mock';
import {
LOG_DISTR,
previewState,
uplotState,
yScale,
} from '../../../../helpers/uplot';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Pinned so the chart is guaranteed to exist with a known series count.
const SERIES = [
{ labels: { 'service.name': 'adservice' }, points: ramp(24, 10, 80) },
{ labels: { 'service.name': 'cartservice' }, points: ramp(24, 20, 60) },
];
// Scope: the ConfigPane sections a TimeSeries / Bar panel declares —
// Visualization, Axes, Legend and Chart Appearance — and that each control
// round-trips into the persisted spec.
//
// Each control is checked twice: it round-trips into the saved spec, and the
// rendered chart honours it (read from the live uPlot instance). The spec alone
// would pass even if the renderer ignored the setting.
/** The plugin spec of the single fixture panel, straight from the API. */
async function savedSpec(
page: Parameters<typeof getDashboardV2ViaApi>[0],
dashboardId: string,
): Promise<Record<string, unknown>> {
const after = await getDashboardV2ViaApi(page, dashboardId);
return after.spec.panels[SINGLE_PANEL_ID].spec.plugin
.spec as unknown as Record<string, unknown>;
}
test.describe('Dashboards V2 — editor sections (TimeSeries / Bar)', () => {
test('TC-01 a TimeSeries panel declares its expected sections', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
for (const title of [
Section.visualization,
Section.formatting,
Section.axes,
Section.legend,
Section.chartAppearance,
Section.thresholds,
Section.contextLinks,
]) {
await expect(sectionToggle(page, title)).toBeVisible();
}
// Buckets belongs to Histogram only.
await expect(sectionToggle(page, Section.buckets)).toHaveCount(0);
});
test('TC-02 sections start collapsed and toggle open', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
const axes = sectionToggle(page, Section.axes);
await expect(axes).toHaveAttribute('aria-expanded', 'false');
await expandSection(page, Section.axes);
await expect(page.getByTestId('panel-editor-v2-soft-min')).toBeVisible();
});
test('TC-03 axis bounds reach the chart', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.timeSeries(SERIES));
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.axes);
await page.getByTestId('panel-editor-v2-soft-min').fill('5');
await page.getByTestId('panel-editor-v2-soft-max').fill('95');
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
axes: { softMin: 5, softMax: 95 },
});
// …and the chart is actually bounded by them.
await expect
.poll(async () => (await yScale(page, SINGLE_PANEL_ID)).min)
.toBeLessThanOrEqual(5);
await expect
.poll(async () => (await yScale(page, SINGLE_PANEL_ID)).max)
.toBeGreaterThanOrEqual(95);
});
test('TC-03b log scale reaches the chart, not just the spec', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.timeSeries(SERIES));
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.axes);
await setSegment(page, 'panel-editor-v2-log-scale', 'Log');
// Live, before saving — uPlot encodes a log scale as distr 3.
await expect
.poll(async () => {
const scales = (await previewState(page)).scales;
return (scales.y ?? Object.values(scales)[1])?.distr;
})
.toBe(LOG_DISTR);
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
axes: { isLogScale: true },
});
// And on the saved panel.
await expect
.poll(async () => (await yScale(page, SINGLE_PANEL_ID)).distr)
.toBe(LOG_DISTR);
});
test('TC-04 clearing an axis bound stores null, not zero', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ pluginSpec: { axes: { softMin: 5 } } }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.axes);
// Must clear the bound, not pin the axis to 0.
await page.getByTestId('panel-editor-v2-soft-min').fill('');
await savePanel(page);
const spec = await savedSpec(page, id);
expect((spec.axes as { softMin?: number | null }).softMin ?? null).toBeNull();
});
test('TC-05 legend position persists', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.legend);
await setSegment(page, 'panel-editor-v2-legend-position', 'Right');
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
legend: { position: 'right' },
});
});
test('TC-06 chart appearance controls reach the chart', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.timeSeries(SERIES));
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.chartAppearance);
await setSegment(page, 'panel-editor-v2-line-style', 'Dashed');
await setSegment(page, 'panel-editor-v2-fill-mode', 'Gradient');
await selectOption(page, 'panel-editor-v2-line-interpolation', 'Step before');
await page.getByTestId('panel-editor-v2-show-points').click();
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
chartAppearance: {
lineStyle: 'dashed',
fillMode: 'gradient',
lineInterpolation: 'step_before',
showPoints: true,
},
});
// The rendered series carry the styling.
await expect
.poll(async () => {
const [first] = (await uplotState(page, SINGLE_PANEL_ID)).series;
return {
dashed: (first?.dash?.length ?? 0) > 0,
filled: first?.hasFill ?? false,
};
})
.toEqual({ dashed: true, filled: true });
// "Show points" is not asserted against the chart: uPlot installs a
// predicate for `points.show` either way, so the seam can't distinguish on
// from off. The spec round-trip above is the available coverage.
});
test('TC-07 the panel time preference persists and drives the header pill', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.visualization);
await selectOption(page, 'panel-editor-v2-time-preference', 'Last 15 min');
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
visualization: { timePreference: 'last_15_min' },
});
// The pill is the user-visible consequence.
await expect(page.getByTestId('panel-time-preference')).toBeVisible();
});
test('TC-08 Fill gaps is offered on TimeSeries but Stack series is not', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.visualization);
await expect(page.getByTestId('panel-editor-v2-fill-spans')).toBeVisible();
await expect(
page.getByTestId('panel-editor-v2-stacked-bar-chart'),
).toHaveCount(0);
await page.getByTestId('panel-editor-v2-fill-spans').click();
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
visualization: { fillSpans: true },
});
});
test('TC-09 Stack series is offered on Bar but Fill gaps is not', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.BarChart }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.visualization);
await expect(
page.getByTestId('panel-editor-v2-stacked-bar-chart'),
).toBeVisible();
await expect(page.getByTestId('panel-editor-v2-fill-spans')).toHaveCount(0);
await page.getByTestId('panel-editor-v2-stacked-bar-chart').click();
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
visualization: { stackedBarChart: true },
});
});
test('TC-10 a section edit marks the editor dirty', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.unsavedBadge(page)).toHaveCount(0);
await expandSection(page, Section.legend);
await setSegment(page, 'panel-editor-v2-legend-position', 'Right');
await expect(editor.unsavedBadge(page)).toBeVisible();
});
});

View File

@@ -0,0 +1,218 @@
import { expect, test } from '../../../../fixtures/dashboards';
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
Section,
expandSection,
savePanel,
searchAndSelectOption,
sectionToggle,
selectOption,
} from '../../../../helpers/panel-editor-v2';
import {
QueryRange,
mockQueryRange,
} from '../../../../helpers/query-range-mock';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the sections that differ across the non-chart kinds.
//
// The asymmetry: TimeSeries/Number/Pie have one `unit`; Table has none and
// carries `columnUnits` per column (hence the fan-out in 30-type-switch TC-07).
async function savedSpec(
page: Parameters<typeof getDashboardV2ViaApi>[0],
dashboardId: string,
): Promise<Record<string, unknown>> {
const after = await getDashboardV2ViaApi(page, dashboardId);
return after.spec.panels[SINGLE_PANEL_ID].spec.plugin
.spec as unknown as Record<string, unknown>;
}
test.describe('Dashboards V2 — editor sections (Number / Table / Pie / Histogram)', () => {
test('TC-01 Number declares formatting but not axes or chart appearance', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Number }),
SINGLE_PANEL_ID,
);
await expect(sectionToggle(page, Section.formatting)).toBeVisible();
await expect(sectionToggle(page, Section.visualization)).toBeVisible();
await expect(sectionToggle(page, Section.axes)).toHaveCount(0);
await expect(sectionToggle(page, Section.chartAppearance)).toHaveCount(0);
await expect(sectionToggle(page, Section.legend)).toHaveCount(0);
});
test('TC-02 a panel-wide unit and decimals persist', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Number }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.formatting);
// Virtualised list — search first.
await searchAndSelectOption(
page,
'panel-editor-v2-unit',
'Milliseconds',
'Milliseconds',
);
await selectOption(page, 'panel-editor-v2-decimals', '3 decimals');
await savePanel(page);
const spec = await savedSpec(page, id);
expect(
(spec.formatting as { decimalPrecision?: string }).decimalPrecision,
).toBe('3');
expect((spec.formatting as { unit?: string }).unit).toBeTruthy();
});
test('TC-03 Table offers per-column units instead of a panel-wide unit', async ({
authedPage: page,
dashboards,
}) => {
// Golden data: the column key is derived from the resolved result
// (`column.id || column.name`), which a hand-rolled payload must match
// exactly or the column renders unnamed.
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
await expandSection(page, Section.formatting);
// Table declares `columnUnits`, never `unit`.
await expect(page.getByTestId('panel-editor-v2-unit')).toHaveCount(0);
await expect(
page.locator('[data-testid^="panel-editor-v2-column-unit-"]').first(),
).toBeVisible();
});
test('TC-04 the column-units editor explains itself before the panel has run', async ({
authedPage: page,
dashboards,
}) => {
// No result means no columns, so the section shows a hint.
await mockQueryRange(page, QueryRange.empty());
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.formatting);
await expect(
page.getByText('Run the panel to set per-column units.'),
).toBeVisible();
});
test('TC-05 a per-column unit persists', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
await expandSection(page, Section.formatting);
// Read the key off the control rather than assuming the derivation.
const selector = page
.locator('[data-testid^="panel-editor-v2-column-unit-"]')
.first();
const testId = (await selector.getAttribute('data-testid')) ?? '';
const columnKey = testId.replace('panel-editor-v2-column-unit-', '');
expect(columnKey).not.toBe('');
await searchAndSelectOption(page, testId, 'Milliseconds', 'Milliseconds');
await savePanel(page);
const spec = await savedSpec(page, id);
const columnUnits = (
spec.formatting as { columnUnits?: Record<string, string> }
).columnUnits;
expect(columnUnits?.[columnKey]).toBeTruthy();
});
test('TC-06 Histogram declares Buckets and only a minimal Visualization', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Histogram }),
SINGLE_PANEL_ID,
);
await expect(sectionToggle(page, Section.buckets)).toBeVisible();
// Histogram's Visualization declares only the type switcher.
await expandSection(page, Section.visualization);
await expect(page.getByTestId('panel-editor-v2-time-preference')).toHaveCount(
0,
);
});
test('TC-07 bucket count and width persist', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Histogram }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.buckets);
await page.getByTestId('panel-editor-v2-bucket-count').fill('40');
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
histogramBuckets: { bucketCount: 40 },
});
});
test('TC-08 merging active queries hides the Legend section', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Histogram }),
SINGLE_PANEL_ID,
);
// One merged distribution has no per-series legend to configure.
await expect(sectionToggle(page, Section.legend)).toBeVisible();
await expandSection(page, Section.buckets);
await page.getByTestId('panel-editor-v2-merge-queries').click();
await expect(sectionToggle(page, Section.legend)).toHaveCount(0);
await savePanel(page);
expect(await savedSpec(page, id)).toMatchObject({
histogramBuckets: { mergeAllActiveQueries: true },
});
});
test('TC-09 Pie declares legend and formatting but no axes', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.PieChart }),
SINGLE_PANEL_ID,
);
await expect(sectionToggle(page, Section.legend)).toBeVisible();
await expect(sectionToggle(page, Section.formatting)).toBeVisible();
await expect(sectionToggle(page, Section.axes)).toHaveCount(0);
await expect(sectionToggle(page, Section.thresholds)).toHaveCount(0);
});
});

View File

@@ -0,0 +1,424 @@
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '../../../../fixtures/dashboards';
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
Section,
collapseSection,
expandSection,
savePanel,
sectionToggle,
selectOption,
} from '../../../../helpers/panel-editor-v2';
import {
QueryRange,
mockQueryRange,
} from '../../../../helpers/query-range-mock';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
/** A Number panel whose value is pinned, so threshold crossings are exact. */
const PINNED_VALUE = 100;
function pinnedNumberValue(page: Parameters<typeof mockQueryRange>[0]) {
return mockQueryRange(
page,
QueryRange.scalar({ aggregationColumns: ['A'], rows: [[PINNED_VALUE]] }),
);
}
/** Inline background on a table cell — set only while a background threshold matches. */
async function cellBackground(cell: Locator): Promise<string> {
return cell.evaluate((node) => (node as HTMLElement).style.backgroundColor);
}
/** The threshold's target column is derived from the live result, so don't assume its name. */
async function pickFirstOption(
page: Page,
triggerTestId: string,
): Promise<void> {
await page.getByTestId(triggerTestId).click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.locator('.ant-select-item-option')
.first()
.click();
}
/** Inline colour on the rendered value — set only while a threshold matches. */
async function renderedValueColor(
page: Parameters<typeof mockQueryRange>[0],
): Promise<string> {
return page
.getByTestId('number-panel-value')
.evaluate((node) => (node as HTMLElement).style.color);
}
// Scope: the Thresholds section across its three variants, and the row
// lifecycle.
//
// Variant follows panel kind, not a user control: label → TimeSeries/Bar,
// comparison → Number, table → Table. The add button's testid varies with it.
const AddButton = {
label: 'panel-editor-v2-add-threshold',
comparison: 'panel-editor-v2-add-comparison-threshold',
table: 'panel-editor-v2-add-table-threshold',
} as const;
async function savedThresholds(
page: Parameters<typeof getDashboardV2ViaApi>[0],
dashboardId: string,
): Promise<{ value?: number; color?: string; label?: string }[]> {
const after = await getDashboardV2ViaApi(page, dashboardId);
const spec = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec as {
thresholds?: { value?: number; color?: string; label?: string }[];
};
return spec.thresholds ?? [];
}
test.describe('Dashboards V2 — editor thresholds', () => {
test('TC-01 a TimeSeries panel gets the label variant', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expandSection(page, Section.thresholds);
await expect(page.getByTestId(AddButton.label)).toBeVisible();
await expect(page.getByTestId(AddButton.comparison)).toHaveCount(0);
await expect(page.getByTestId(AddButton.table)).toHaveCount(0);
});
test('TC-02 a Number panel gets the comparison variant', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Number }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await expect(page.getByTestId(AddButton.comparison)).toBeVisible();
await expect(page.getByTestId(AddButton.label)).toHaveCount(0);
});
test('TC-03 a Table panel gets the table variant', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await expect(page.getByTestId(AddButton.table)).toBeVisible();
await expect(page.getByTestId(AddButton.label)).toHaveCount(0);
});
test('TC-04 adding a threshold and saving persists it', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.label).click();
await page.getByTestId('threshold-value-0').fill('42');
await page.getByTestId('threshold-label-0').fill('Too high');
await page.getByTestId('threshold-save-0').click();
await savePanel(page);
const thresholds = await savedThresholds(page, id);
expect(thresholds).toHaveLength(1);
expect(thresholds[0]).toMatchObject({ value: 42, label: 'Too high' });
});
test('TC-05 the header quick-add expands a collapsed section and adds a row', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
// One click must both expand and add (SectionSlot's pendingAction hop).
await collapseSection(page, Section.thresholds);
await page.getByTestId('panel-editor-v2-add-threshold-header').click();
await expect(sectionToggle(page, Section.thresholds)).toHaveAttribute(
'aria-expanded',
'true',
);
await expect(page.getByTestId('threshold-value-0')).toBeVisible();
});
test('TC-06 only one row is editable at a time', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.label).click();
await page.getByTestId('threshold-value-0').fill('10');
await page.getByTestId('threshold-save-0').click();
// The first row must fall back to its summary.
await page.getByTestId(AddButton.label).click();
await expect(page.getByTestId('threshold-value-1')).toBeVisible();
await expect(page.getByTestId('threshold-value-0')).toHaveCount(0);
});
test('TC-07 discarding a freshly added row removes it entirely', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.label).click();
await page.getByTestId('threshold-value-0').fill('7');
// Discard on a NEW row deletes it.
await page.getByTestId('threshold-discard-0').click();
await expect(page.getByTestId('threshold-value-0')).toHaveCount(0);
await expect(page.getByTestId('threshold-edit-0')).toHaveCount(0);
});
test('TC-08 discarding an existing row restores its previous value', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({
pluginSpec: { thresholds: [{ value: 11, color: 'Red' }] },
}),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await page.getByTestId('threshold-edit-0').click();
await page.getByTestId('threshold-value-0').fill('999');
// Discard on an EXISTING row restores the snapshot.
await page.getByTestId('threshold-discard-0').click();
await savePanel(page);
const thresholds = await savedThresholds(page, id);
expect(thresholds).toHaveLength(1);
expect(thresholds[0].value).toBe(11);
});
test('TC-09 a threshold can be removed', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({
pluginSpec: { thresholds: [{ value: 11, color: 'Red' }] },
}),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await page.getByTestId('threshold-remove-0').click();
await savePanel(page);
expect(await savedThresholds(page, id)).toHaveLength(0);
});
test('TC-11 a crossed threshold colours the rendered value', async ({
authedPage: page,
dashboards,
}) => {
// A renderer ignoring `thresholds` would still save the right JSON, so
// assert the PANEL: value pinned at 100, threshold fires above 50.
await pinnedNumberValue(page);
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: PanelKind.Number,
pluginSpec: {
thresholds: [
{
value: 50,
color: 'Red',
operator: 'above',
format: 'text',
},
],
},
}),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('number-panel-value')).toBeVisible();
await expect.poll(() => renderedValueColor(page)).not.toBe('');
});
test('TC-12 raising the threshold past the value clears the colour live', async ({
authedPage: page,
dashboards,
}) => {
await pinnedNumberValue(page);
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: PanelKind.Number,
pluginSpec: {
thresholds: [
{ value: 50, color: 'Red', operator: 'above', format: 'text' },
],
},
}),
SINGLE_PANEL_ID,
);
await expect.poll(() => renderedValueColor(page)).not.toBe('');
// Edits stream into the preview as you type — no Run, no Save.
await expandSection(page, Section.thresholds);
await page.getByTestId('comparison-threshold-edit-0').click();
await page.getByTestId('comparison-threshold-value-0').fill('500');
await expect.poll(() => renderedValueColor(page)).toBe('');
});
test('TC-13 the threshold colour survives save and shows on the dashboard', async ({
authedPage: page,
dashboards,
}) => {
await pinnedNumberValue(page);
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Number }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.comparison).click();
await page.getByTestId('comparison-threshold-value-0').fill('50');
await selectOption(page, 'comparison-threshold-operator-0', 'Above (>)');
await page.getByTestId('comparison-threshold-save-0').click();
await savePanel(page);
// End-to-end: the saved panel on the dashboard renders the colour.
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
await expect(page.getByTestId('number-panel-value')).toBeVisible();
await expect.poll(() => renderedValueColor(page)).not.toBe('');
});
test('TC-14 a table threshold paints the targeted cell background', async ({
authedPage: page,
dashboards,
}) => {
// The column key is derived from the live result, so pick it from the
// dropdown; ">= 0" fires on any non-negative value.
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
const valueCell = page
.getByTestId('table-panel-renderer')
.locator('tbody tr.ant-table-row')
.first()
.locator('td')
.last();
await expect(valueCell).toBeVisible();
expect(await cellBackground(valueCell)).toBe('');
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.table).click();
await pickFirstOption(page, 'table-threshold-column-0');
await page.getByTestId('table-threshold-value-0').fill('0');
await selectOption(page, 'table-threshold-operator-0', 'Above or equal');
await selectOption(page, 'table-threshold-format-0', 'Background');
await page.getByTestId('table-threshold-save-0').click();
// Live in the preview, before saving.
await expect.poll(() => cellBackground(valueCell)).not.toBe('');
await savePanel(page);
// And on the saved panel back on the dashboard.
await page.waitForURL(new RegExp(`/dashboard/${id}(\\?|$)`));
const savedCell = page
.getByTestId('table-panel-renderer')
.locator('tbody tr.ant-table-row')
.first()
.locator('td')
.last();
await expect(savedCell).toBeVisible();
await expect.poll(() => cellBackground(savedCell)).not.toBe('');
});
test('TC-15 the text format colours the value, not the cell', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table }),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
const valueCell = page
.getByTestId('table-panel-renderer')
.locator('tbody tr.ant-table-row')
.first()
.locator('td')
.last();
await expect(valueCell).toBeVisible();
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.table).click();
await pickFirstOption(page, 'table-threshold-column-0');
await page.getByTestId('table-threshold-value-0').fill('0');
await selectOption(page, 'table-threshold-operator-0', 'Above or equal');
await selectOption(page, 'table-threshold-format-0', 'Text');
await page.getByTestId('table-threshold-save-0').click();
// Text recolours the value; background paints the cell. Not interchangeable.
await expect
.poll(async () => valueCell.locator('span[style*="color"]').count())
.toBeGreaterThan(0);
expect(await cellBackground(valueCell)).toBe('');
});
test('TC-10 the comparison variant persists its operator and display mode', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Number }),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.thresholds);
await page.getByTestId(AddButton.comparison).click();
await page.getByTestId('comparison-threshold-value-0').fill('5');
await selectOption(page, 'comparison-threshold-operator-0', 'Below (<)');
await selectOption(page, 'comparison-threshold-format-0', 'Background');
await page.getByTestId('comparison-threshold-save-0').click();
await savePanel(page);
const after = await getDashboardV2ViaApi(page, id);
const thresholds = (
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec as {
thresholds?: { operator?: string; format?: string; value?: number }[];
}
).thresholds;
expect(thresholds?.[0]).toMatchObject({
value: 5,
operator: 'below',
format: 'background',
});
});
});

View File

@@ -0,0 +1,204 @@
import { expect, test } from '../../../../fixtures/dashboards';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
Section,
collapseSection,
expandSection,
savePanel,
sectionToggle,
} from '../../../../helpers/panel-editor-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the Context Links dialog and that a saved link reaches
// `panel.spec.links` — where it surfaces as a `drilldown-context-link`
// (covered in panels/57-drilldown).
/**
* Close the URL field's suggestion popover, which otherwise keeps the dialog
* reflowing so buttons never settle. Escape would dismiss the whole dialog.
*/
async function blurDialogFields(
page: Parameters<typeof getDashboardV2ViaApi>[0],
): Promise<void> {
await page.getByTestId('context-link-label').focus();
}
async function savedLinks(
page: Parameters<typeof getDashboardV2ViaApi>[0],
dashboardId: string,
): Promise<{ name?: string; url?: string }[]> {
const after = await getDashboardV2ViaApi(page, dashboardId);
return after.spec.panels[SINGLE_PANEL_ID].spec.links ?? [];
}
test.describe('Dashboards V2 — editor context links', () => {
test('TC-01 the section is offered and opens an empty dialog', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expandSection(page, Section.contextLinks);
await page.getByTestId('panel-editor-v2-add-link').click();
const dialog = page.getByTestId('context-link-dialog');
await expect(dialog).toBeVisible();
await expect(page.getByTestId('context-link-label')).toHaveValue('');
await expect(page.getByTestId('context-link-url')).toHaveValue('');
});
test('TC-02 Save stays disabled until the link is valid', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expandSection(page, Section.contextLinks);
await page.getByTestId('panel-editor-v2-add-link').click();
// A link with no URL would be a dead menu entry.
await expect(page.getByTestId('context-link-save')).toBeDisabled();
await page.getByTestId('context-link-label').fill('Runbook');
await expect(page.getByTestId('context-link-save')).toBeDisabled();
await page
.getByTestId('context-link-url')
.fill('https://example.com/runbook');
await expect(page.getByTestId('context-link-save')).toBeEnabled();
});
test('TC-03 a saved link persists into panel.spec.links', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.contextLinks);
await page.getByTestId('panel-editor-v2-add-link').click();
await page.getByTestId('context-link-label').fill('Runbook');
await page
.getByTestId('context-link-url')
.fill('https://example.com/runbook');
await blurDialogFields(page);
await page.getByTestId('context-link-save').click();
await expect(page.getByTestId('context-link-item-0')).toBeVisible();
await savePanel(page);
const links = await savedLinks(page, id);
expect(links).toHaveLength(1);
// "Label" persists as `name` (Perses link model).
expect(links[0]).toMatchObject({
name: 'Runbook',
url: 'https://example.com/runbook',
});
});
test('TC-04 Cancel discards the dialog without adding a link', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expandSection(page, Section.contextLinks);
await page.getByTestId('panel-editor-v2-add-link').click();
await page.getByTestId('context-link-label').fill('Throwaway');
await page.getByTestId('context-link-url').fill('https://example.com');
await blurDialogFields(page);
await page.getByTestId('context-link-cancel').click();
await expect(page.getByTestId('context-link-dialog')).toHaveCount(0);
await expect(page.getByTestId('context-link-item-0')).toHaveCount(0);
});
test('TC-05 an existing link can be edited', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({
links: [{ name: 'Original', url: 'https://example.com/one' }],
}),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.contextLinks);
await page.getByTestId('context-link-edit-0').click();
await page.getByTestId('context-link-label').fill('Renamed');
await blurDialogFields(page);
await page.getByTestId('context-link-save').click();
await savePanel(page);
const links = await savedLinks(page, id);
expect(links[0]).toMatchObject({ name: 'Renamed' });
});
test('TC-06 a link can be removed', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({
links: [{ name: 'Original', url: 'https://example.com/one' }],
}),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.contextLinks);
await page.getByTestId('context-link-remove-0').click();
await savePanel(page);
expect(await savedLinks(page, id)).toHaveLength(0);
});
test('TC-07 URL parameters can be added to a link', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await expandSection(page, Section.contextLinks);
await page.getByTestId('panel-editor-v2-add-link').click();
await page.getByTestId('context-link-label').fill('With params');
await page.getByTestId('context-link-url').fill('https://example.com/search');
await blurDialogFields(page);
await page.getByTestId('context-link-add-param').click();
await page.getByTestId('context-link-param-key-0').fill('service');
await page.getByTestId('context-link-param-value-0').fill('adservice');
await blurDialogFields(page);
await page.getByTestId('context-link-save').click();
await savePanel(page);
const links = await savedLinks(page, id);
expect(links).toHaveLength(1);
// Params fold into the persisted URL.
expect(JSON.stringify(links[0])).toContain('service');
});
test('TC-08 the header quick-add opens the dialog from a collapsed section', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
// One click must expand AND add (pendingAction hop).
await collapseSection(page, Section.contextLinks);
await page.getByTestId('panel-editor-v2-add-link-header').click();
await expect(sectionToggle(page, Section.contextLinks)).toHaveAttribute(
'aria-expanded',
'true',
);
await expect(page.getByTestId('context-link-dialog')).toBeVisible();
});
});

View File

@@ -0,0 +1,180 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
PanelKind,
logsCountQuery,
rawQuery,
} from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import { editor, savePanel } from '../../../../helpers/panel-editor-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the List columns editor — the one per-kind control that lives in the
// query builder's footer rather than the ConfigPane. Persists to
// `plugin.spec.selectFields`.
async function savedFields(
page: Parameters<typeof getDashboardV2ViaApi>[0],
dashboardId: string,
): Promise<{ name?: string }[]> {
const after = await getDashboardV2ViaApi(page, dashboardId);
const spec = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec as {
selectFields?: { name?: string }[];
};
return spec.selectFields ?? [];
}
function listDashboard(fields?: { name: string; signal?: 'logs' }[]) {
return singlePanelDashboard({
kind: PanelKind.List,
query: rawQuery({ signal: 'logs' }),
...(fields ? { pluginSpec: { selectFields: fields } } : {}),
});
}
test.describe('Dashboards V2 — editor list columns', () => {
test('TC-01 the columns editor renders only for List panels', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(listDashboard(), SINGLE_PANEL_ID);
await expect(page.getByTestId('list-columns-editor')).toBeVisible();
});
test('TC-02 a TimeSeries panel has no columns editor', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.queryBuilder(page)).toBeVisible();
await expect(page.getByTestId('list-columns-editor')).toHaveCount(0);
});
test('TC-03 seeded columns render as chips', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
listDashboard([
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
]),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('list-column-chip-timestamp')).toBeVisible();
await expect(page.getByTestId('list-column-chip-body')).toBeVisible();
});
test('TC-04 a column can be removed and the removal persists', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
listDashboard([
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
]),
SINGLE_PANEL_ID,
);
await page.getByTestId('list-column-remove-body').click();
await expect(page.getByTestId('list-column-chip-body')).toHaveCount(0);
await savePanel(page);
const fields = await savedFields(page, id);
expect(fields.map((field) => field.name)).not.toContain('body');
});
test('TC-05 a custom column can be added by free text', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
listDashboard([{ name: 'timestamp', signal: 'logs' }]),
SINGLE_PANEL_ID,
);
await page.getByTestId('list-columns-add').click();
await page.getByTestId('list-columns-search').fill('my_custom_field');
// Explicit "Add …" entry, so a typo can't be committed by blurring.
await page.getByTestId('list-columns-add-custom').click();
await expect(
page.getByTestId('list-column-chip-my_custom_field'),
).toBeVisible();
await savePanel(page);
const fields = await savedFields(page, id);
expect(fields.map((field) => field.name)).toContain('my_custom_field');
});
test('TC-06 a suggested field can be added from the dropdown', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
listDashboard([{ name: 'timestamp', signal: 'logs' }]),
SINGLE_PANEL_ID,
);
await page.getByTestId('list-columns-add').click();
await page.getByTestId('list-columns-search').fill('service');
// The list repaints as backend results land; waiting for the loading row
// avoids resolving `.first()` against a node about to be replaced.
await expect(page.getByText('Loading…')).toHaveCount(0);
const suggestion = page
.locator('[data-testid^="list-columns-suggestion-"]')
.first();
await expect(suggestion).toBeVisible();
const testId = (await suggestion.getAttribute('data-testid')) ?? '';
const fieldName = testId.replace('list-columns-suggestion-', '');
await suggestion.click();
await savePanel(page);
const fields = await savedFields(page, id);
expect(fields.map((field) => field.name)).toContain(fieldName);
});
test('TC-07 an empty column set is allowed and explains itself', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndEdit(
listDashboard([{ name: 'timestamp', signal: 'logs' }]),
SINGLE_PANEL_ID,
);
await page.getByTestId('list-column-remove-timestamp').click();
// Empty means "show everything the query returns".
await expect(
page.getByText('Leave empty to show all fields returned by the query.'),
).toBeVisible();
});
test('TC-08 switching a List panel to Table drops the columns editor', async ({
authedPage: page,
dashboards,
}) => {
// Logs-shaped so Table stays a legal target.
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: PanelKind.List,
query: logsCountQuery(),
}),
SINGLE_PANEL_ID,
);
await expect(page.getByTestId('list-columns-editor')).toBeVisible();
await page.getByTestId('panel-editor-v2-type-switcher').click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('Table', { exact: true })
.click();
await expect(page.getByTestId('list-columns-editor')).toHaveCount(0);
});
});