mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Compare commits
3 Commits
test/dashb
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea77fa345 | ||
|
|
35888d0006 | ||
|
|
f5c48c1b66 |
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ResizableHeaderProps extends Omit<
|
||||
> {
|
||||
width?: number;
|
||||
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
|
||||
/** Column key, used only to give the drag grip a stable test handle. */
|
||||
columnKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,7 @@ export interface ResizableHeaderProps extends Omit<
|
||||
function ResizableHeader({
|
||||
width,
|
||||
onResize,
|
||||
columnKey,
|
||||
...restProps
|
||||
}: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
@@ -34,13 +37,14 @@ function ResizableHeader({
|
||||
<span
|
||||
className={styles.handle}
|
||||
role="presentation"
|
||||
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
|
||||
// Stop the grip's click from reaching the column sorter underneath.
|
||||
// The grip is a pointer-only resize affordance, not keyboard-actionable.
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[columnKey],
|
||||
);
|
||||
|
||||
if (!width || !onResize) {
|
||||
@@ -64,6 +68,7 @@ function ResizableHeader({
|
||||
ResizableHeader.defaultProps = {
|
||||
width: undefined,
|
||||
onResize: undefined,
|
||||
columnKey: undefined,
|
||||
};
|
||||
|
||||
export default ResizableHeader;
|
||||
|
||||
@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
columnKey: key,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
|
||||
288
tests/e2e/helpers/panel-editor-v2.ts
Normal file
288
tests/e2e/helpers/panel-editor-v2.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// Locators and interactions for the V2 panel editor.
|
||||
//
|
||||
// Two gotchas: antd popups portal to `document.body` (a testid finds the
|
||||
// TRIGGER, options live in a detached `.ant-select-dropdown`), and config
|
||||
// sections only mount their editors while open.
|
||||
|
||||
// ─── Labels ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const EditorText = {
|
||||
title: 'Configure panel',
|
||||
unsavedBadge: 'Unsaved Changes',
|
||||
save: 'Save changes',
|
||||
switchToView: 'Switch to View Mode',
|
||||
discardTitle: 'Discard changes?',
|
||||
discardBody: 'Your unsaved edits to this panel will be lost.',
|
||||
savedToast: 'Panel saved',
|
||||
lockedReason: 'This dashboard is locked',
|
||||
runQuery: 'Run Query',
|
||||
} as const;
|
||||
|
||||
export const QueryTab = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse Query',
|
||||
promql: 'PromQL',
|
||||
} as const;
|
||||
|
||||
/** SettingsSection titles, as rendered — `sectionTestId` slugifies them. */
|
||||
export const Section = {
|
||||
visualization: 'Visualization',
|
||||
formatting: 'Formatting & Units',
|
||||
axes: 'Axes',
|
||||
legend: 'Legend',
|
||||
chartAppearance: 'Chart Appearance',
|
||||
buckets: 'Histogram / Buckets',
|
||||
thresholds: 'Thresholds',
|
||||
contextLinks: 'Context Links',
|
||||
} as const;
|
||||
|
||||
/** Slugified as `title.toLowerCase().replace(/\s+/g,'-')` — `&` and `/` survive. */
|
||||
export function sectionTestId(title: string): string {
|
||||
return `config-section-${title.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
}
|
||||
|
||||
// ─── Shell locators ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `panel-editor-v2` is NOT unique — the ResizablePanelGroup derives the same
|
||||
* testid from its `id` (also the localStorage layout key, so unrenameable).
|
||||
* `:not([data-group])` picks the page root.
|
||||
*/
|
||||
const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
|
||||
|
||||
export const editor = {
|
||||
root: (page: Page): Locator => page.locator(EDITOR_ROOT),
|
||||
title: (page: Page): Locator => page.getByTestId('panel-editor-v2-title'),
|
||||
description: (page: Page): Locator =>
|
||||
page.getByTestId('panel-editor-v2-description'),
|
||||
save: (page: Page): Locator => page.getByTestId('panel-editor-v2-save'),
|
||||
close: (page: Page): Locator => page.getByTestId('panel-editor-v2-close'),
|
||||
unsavedBadge: (page: Page): Locator =>
|
||||
page.getByTestId('panel-editor-v2-unsaved-badge'),
|
||||
switchToView: (page: Page): Locator =>
|
||||
page.getByTestId('panel-editor-v2-switch-to-view'),
|
||||
typeSwitcher: (page: Page): Locator =>
|
||||
page.getByTestId('panel-editor-v2-type-switcher'),
|
||||
queryBuilder: (page: Page): Locator =>
|
||||
page.getByTestId('panel-editor-v2-query-builder'),
|
||||
};
|
||||
|
||||
// ─── Sections ────────────────────────────────────────────────────────────
|
||||
|
||||
export function sectionToggle(page: Page, title: string): Locator {
|
||||
return page.getByTestId(sectionTestId(title));
|
||||
}
|
||||
|
||||
/** Idempotent: a blind click on an open section would collapse it. */
|
||||
export async function expandSection(page: Page, title: string): Promise<void> {
|
||||
const toggle = sectionToggle(page, title);
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
export async function collapseSection(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const toggle = sectionToggle(page, title);
|
||||
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
// ─── antd Select helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a Select and resolve ITS dropdown via `aria-controls`. A closing
|
||||
* dropdown still matches `:not(.ant-select-dropdown-hidden)`, so opening two
|
||||
* Selects in a row otherwise trips strict mode.
|
||||
*/
|
||||
async function openDropdown(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
): Promise<Locator> {
|
||||
const trigger = page.getByTestId(triggerTestId);
|
||||
await trigger.click();
|
||||
const listId = await trigger.locator('input').getAttribute('aria-controls');
|
||||
const dropdown = listId
|
||||
? page
|
||||
.locator(`#${listId}`)
|
||||
.locator('xpath=ancestor::div[contains(@class,"ant-select-dropdown")][1]')
|
||||
: page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.last();
|
||||
await expect(dropdown).toBeVisible();
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
/** Pick an option from an antd Select identified by the trigger's testid. */
|
||||
export async function selectOption(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
optionLabel: string,
|
||||
): Promise<void> {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
/** Searches first — long option lists are virtualised (needed for unit pickers). */
|
||||
export async function searchAndSelectOption(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
searchTerm: string,
|
||||
optionLabel: string,
|
||||
): Promise<void> {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await page.getByTestId(triggerTestId).locator('input').fill(searchTerm);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
/** Read the option labels a Select currently offers, plus their disabled state. */
|
||||
export async function selectOptions(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
): Promise<{ label: string; disabled: boolean }[]> {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
return dropdown.locator('.ant-select-item-option').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label: node.textContent?.trim() ?? '',
|
||||
disabled: node.classList.contains('ant-select-item-option-disabled'),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Segmented / switch controls ─────────────────────────────────────────
|
||||
|
||||
/** Segments carry `aria-label`; the testid is on the group. */
|
||||
export async function setSegment(
|
||||
page: Page,
|
||||
groupTestId: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(groupTestId).locator(`[aria-label="${label}"]`).click();
|
||||
}
|
||||
|
||||
export function segment(
|
||||
page: Page,
|
||||
groupTestId: string,
|
||||
label: string,
|
||||
): Locator {
|
||||
return page.getByTestId(groupTestId).locator(`[aria-label="${label}"]`);
|
||||
}
|
||||
|
||||
// ─── Query builder ───────────────────────────────────────────────────────
|
||||
|
||||
/** The Run button has no testid at this call site, so match by role. */
|
||||
export async function runQuery(page: Page): Promise<void> {
|
||||
const response = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.getByRole('button', { name: EditorText.runQuery }).click();
|
||||
await response;
|
||||
}
|
||||
|
||||
export function queryTab(page: Page, label: string): Locator {
|
||||
return editor.queryBuilder(page).getByRole('tab', { name: label });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a metric. An antd AutoComplete: testid is on a wrapper, options are
|
||||
* fetched as you type. Required before a new metrics panel can be saved — the
|
||||
* backend rejects an empty aggregation with "metric name is required".
|
||||
*/
|
||||
export async function selectMetric(
|
||||
page: Page,
|
||||
metricName: string,
|
||||
index = 0,
|
||||
): Promise<void> {
|
||||
const field = page.getByTestId(`metric-name-selector-${index}`);
|
||||
await field.click();
|
||||
await field.locator('input').fill(metricName);
|
||||
const dropdown = page.locator(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
||||
);
|
||||
await expect(dropdown).toBeVisible();
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
// ─── Save / discard ──────────────────────────────────────────────────────
|
||||
|
||||
/** Save is NOT gated on dirty state — a pristine panel still saves. */
|
||||
export async function savePanel(page: Page): Promise<void> {
|
||||
const patch = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PATCH' && /\/api\/v2\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await editor.save(page).click();
|
||||
const response = await patch;
|
||||
// A rejected patch leaves the editor open, which would surface as an
|
||||
// unrelated timeout several lines later.
|
||||
expect(
|
||||
response.ok(),
|
||||
`PATCH ${response.url()} failed: ${response.status()} ${await response.text()}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
/** A dirty panel raises the discard dialog; a pristine one closes immediately. */
|
||||
export async function closeEditor(
|
||||
page: Page,
|
||||
options?: { expectDirty?: boolean; keepEditing?: boolean },
|
||||
): Promise<void> {
|
||||
await editor.close(page).click();
|
||||
if (!options?.expectDirty) {
|
||||
return;
|
||||
}
|
||||
await expect(page.getByTestId('panel-editor-v2-discard-modal')).toBeVisible();
|
||||
await page
|
||||
.getByTestId(
|
||||
options.keepEditing
|
||||
? 'panel-editor-v2-discard-cancel'
|
||||
: 'panel-editor-v2-discard-confirm',
|
||||
)
|
||||
.click();
|
||||
}
|
||||
|
||||
// ─── Patch capture ───────────────────────────────────────────────────────
|
||||
|
||||
export interface PatchOperation {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the RFC-6902 ops sent on save. Stricter than re-reading the dashboard:
|
||||
* catches a whole-spec replace that would clobber concurrent edits.
|
||||
*/
|
||||
export function capturePatchOps(page: Page): PatchOperation[][] {
|
||||
const batches: PatchOperation[][] = [];
|
||||
page.on('request', (request) => {
|
||||
if (
|
||||
request.method() !== 'PATCH' ||
|
||||
!/\/api\/v2\/dashboards\//.test(request.url())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as PatchOperation[] | null;
|
||||
if (body) {
|
||||
batches.push(body);
|
||||
}
|
||||
});
|
||||
return batches;
|
||||
}
|
||||
225
tests/e2e/tests/dashboards/v2/editor/03-creation.spec.ts
Normal file
225
tests/e2e/tests/dashboards/v2/editor/03-creation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
201
tests/e2e/tests/dashboards/v2/editor/12-shell.spec.ts
Normal file
201
tests/e2e/tests/dashboards/v2/editor/12-shell.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
182
tests/e2e/tests/dashboards/v2/editor/21-query.spec.ts
Normal file
182
tests/e2e/tests/dashboards/v2/editor/21-query.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
198
tests/e2e/tests/dashboards/v2/editor/30-type-switch.spec.ts
Normal file
198
tests/e2e/tests/dashboards/v2/editor/30-type-switch.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
209
tests/e2e/tests/dashboards/v2/editor/39-capabilities.spec.ts
Normal file
209
tests/e2e/tests/dashboards/v2/editor/39-capabilities.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
424
tests/e2e/tests/dashboards/v2/editor/66-thresholds.spec.ts
Normal file
424
tests/e2e/tests/dashboards/v2/editor/66-thresholds.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
204
tests/e2e/tests/dashboards/v2/editor/75-context-links.spec.ts
Normal file
204
tests/e2e/tests/dashboards/v2/editor/75-context-links.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
180
tests/e2e/tests/dashboards/v2/editor/84-list-columns.spec.ts
Normal file
180
tests/e2e/tests/dashboards/v2/editor/84-list-columns.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
231
tests/e2e/tests/dashboards/v2/panels/12-actions-menu.spec.ts
Normal file
231
tests/e2e/tests/dashboards/v2/panels/12-actions-menu.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import {
|
||||
getDashboardV2ViaApi,
|
||||
gotoDashboardV2,
|
||||
setDashboardLockedViaApi,
|
||||
} from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
PanelAction,
|
||||
closePanelActions,
|
||||
downloadPanelAs,
|
||||
openPanelActions,
|
||||
panelRoot,
|
||||
runPanelAction,
|
||||
} from '../../../../helpers/panels-v2';
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
compactDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the panel ⋮ menu — which items exist per kind, how capability and the
|
||||
// dashboard lock gate them, and that the mutating ones reach the spec.
|
||||
//
|
||||
// Items carry no testid, so everything matches role + visible label.
|
||||
|
||||
test.describe('Dashboards V2 — panel actions menu', () => {
|
||||
test('TC-01 an editable panel offers the full action set', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
await openPanelActions(page, COMPACT_PANELS.timeseries);
|
||||
|
||||
for (const label of [
|
||||
PanelAction.view,
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.download,
|
||||
PanelAction.createAlert,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: label, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 Download offers CSV only on Table', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
// Table declares `csv: true`; others expose PNG/SVG only.
|
||||
await openPanelActions(page, COMPACT_PANELS.table);
|
||||
await page
|
||||
.getByRole('menuitem', { name: PanelAction.download, exact: true })
|
||||
.hover();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: PanelAction.downloadCsv, exact: true }),
|
||||
).toBeVisible();
|
||||
await closePanelActions(page);
|
||||
|
||||
await openPanelActions(page, COMPACT_PANELS.timeseries);
|
||||
await page
|
||||
.getByRole('menuitem', { name: PanelAction.download, exact: true })
|
||||
.hover();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: PanelAction.downloadPng, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: PanelAction.downloadCsv, exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 Create Alerts is hidden for kinds that do not declare it', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await openPanelActions(page, COMPACT_PANELS.timeseries);
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: PanelAction.createAlert, exact: true }),
|
||||
).toBeVisible();
|
||||
await closePanelActions(page);
|
||||
|
||||
for (const panelId of [COMPACT_PANELS.table, COMPACT_PANELS.list]) {
|
||||
await openPanelActions(page, panelId);
|
||||
await expect(
|
||||
page.getByRole('menuitem', {
|
||||
name: PanelAction.createAlert,
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await closePanelActions(page);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 Create Alerts opens the alert builder in a new tab', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const popup = page.context().waitForEvent('page');
|
||||
await runPanelAction(
|
||||
page,
|
||||
COMPACT_PANELS.timeseries,
|
||||
PanelAction.createAlert,
|
||||
);
|
||||
const alertTab = await popup;
|
||||
await expect(alertTab).toHaveURL(/\/alerts\/new/);
|
||||
await alertTab.close();
|
||||
});
|
||||
|
||||
// Chromium-only: headless Firefox/WebKit don't surface the canvas blob as a
|
||||
// Playwright download event. CSV and SVG are unaffected.
|
||||
test('TC-05 Download as PNG produces a file', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'headless Firefox/WebKit do not emit a download event for the canvas blob',
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
await panelRoot(page, COMPACT_PANELS.timeseries).scrollIntoViewIfNeeded();
|
||||
|
||||
const download = page.waitForEvent('download');
|
||||
await downloadPanelAs(page, COMPACT_PANELS.timeseries, 'PNG');
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
test('TC-06 Clone adds a second panel and persists it', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const before = await getDashboardV2ViaApi(page, id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await runPanelAction(page, COMPACT_PANELS.timeseries, PanelAction.clone);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
return Object.keys(after.spec.panels).length;
|
||||
})
|
||||
.toBe(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-07 Delete panel removes the panel and its layout item', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await runPanelAction(page, COMPACT_PANELS.list, PanelAction.delete);
|
||||
await expect(page.getByText('Delete panel?')).toBeVisible();
|
||||
await page.getByTestId('confirm-delete').click();
|
||||
|
||||
await expect(panelRoot(page, COMPACT_PANELS.list)).toHaveCount(0);
|
||||
|
||||
// Optimistic: the panel leaves the DOM before the PATCH lands.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
return after.spec.panels[COMPACT_PANELS.list];
|
||||
})
|
||||
.toBeUndefined();
|
||||
|
||||
// The grid item must go too, or a dangling $ref renders an empty tile.
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
const refs = after.spec.layouts.flatMap((layout) =>
|
||||
layout.spec.items.map((item) => item.content.$ref),
|
||||
);
|
||||
expect(refs).not.toContain(`#/spec/panels/${COMPACT_PANELS.list}`);
|
||||
});
|
||||
|
||||
test('TC-08 Move to section relocates the panel between sections', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await openPanelActions(page, COMPACT_PANELS.timeseries);
|
||||
await page
|
||||
.getByRole('menuitem', { name: PanelAction.move, exact: true })
|
||||
.hover();
|
||||
await page.getByRole('menuitem', { name: 'Tabular', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
const target = after.spec.layouts.find(
|
||||
(layout) => layout.spec.display.title === 'Tabular',
|
||||
);
|
||||
return target?.spec.items.some(
|
||||
(item) =>
|
||||
item.content.$ref === `#/spec/panels/${COMPACT_PANELS.timeseries}`,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-09 a locked dashboard disables the mutating actions', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seed(compactDashboard());
|
||||
await setDashboardLockedViaApi(page, id, true);
|
||||
await gotoDashboardV2(page, id);
|
||||
|
||||
await openPanelActions(page, COMPACT_PANELS.timeseries);
|
||||
|
||||
// View and Download don't mutate, so they stay available.
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: PanelAction.view, exact: true }),
|
||||
).toBeEnabled();
|
||||
|
||||
for (const label of [PanelAction.edit, PanelAction.clone]) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: label, exact: true }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
218
tests/e2e/tests/dashboards/v2/panels/48-table-list.spec.ts
Normal file
218
tests/e2e/tests/dashboards/v2/panels/48-table-list.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
|
||||
import {
|
||||
boundingBoxOf,
|
||||
listPager,
|
||||
panelRoot,
|
||||
searchInPanel,
|
||||
} from '../../../../helpers/panels-v2';
|
||||
import {
|
||||
QueryRange,
|
||||
mockQueryRange,
|
||||
mockQueryRangeSequence,
|
||||
} from '../../../../helpers/query-range-mock';
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
compactDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the tabular-only controls — header search, column resize, and List's
|
||||
// server-side pager. Paging is mocked (it needs a stable row set).
|
||||
|
||||
// Next only enables when a response FILLS the page, so page one must be full.
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const LOG_ROWS = Array.from({ length: PAGE_SIZE }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
body: `page-one line ${i}`,
|
||||
'service.name': 'adservice',
|
||||
}));
|
||||
|
||||
const LOG_ROWS_PAGE_TWO = Array.from({ length: 6 }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 1, i)).toISOString(),
|
||||
body: `page-two line ${i}`,
|
||||
'service.name': 'cartservice',
|
||||
}));
|
||||
|
||||
test.describe('Dashboards V2 — table and list controls', () => {
|
||||
test('TC-01 header search filters the table and can be cleared', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
['frontend', 30],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const root = panelRoot(page, COMPACT_PANELS.table);
|
||||
await root.scrollIntoViewIfNeeded();
|
||||
await expect(root.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
|
||||
const rows = root.locator('tbody tr.ant-table-row');
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
await searchInPanel(page, COMPACT_PANELS.table, 'cart');
|
||||
await expect(rows).toHaveCount(1);
|
||||
|
||||
await root.getByTestId('panel-header-search-clear').click();
|
||||
await expect(rows).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('TC-02 Escape closes the search box', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const root = panelRoot(page, COMPACT_PANELS.table);
|
||||
await root.scrollIntoViewIfNeeded();
|
||||
await searchInPanel(page, COMPACT_PANELS.table, 'cart');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(root.getByTestId('panel-header-search-input')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 search is not offered on kinds that do not declare it', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const chart = panelRoot(page, COMPACT_PANELS.timeseries);
|
||||
await chart.hover();
|
||||
await expect(chart.getByTestId('panel-header-search-trigger')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-04 a resized column persists across a reload', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [['adservice', 10]],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.Table }));
|
||||
|
||||
const root = panelRoot(page, SINGLE_PANEL_ID);
|
||||
await expect(root.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
|
||||
const header = root.locator('th').filter({ hasText: 'service.name' });
|
||||
const before = (await header.boundingBox())?.width ?? 0;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
const gripBox = await boundingBoxOf(
|
||||
root.getByTestId('column-resize-service.name'),
|
||||
'the resize grip',
|
||||
);
|
||||
await page.mouse.move(
|
||||
gripBox.x + gripBox.width / 2,
|
||||
gripBox.y + gripBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(gripBox.x + 120, gripBox.y + gripBox.height / 2, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await header.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
// Widths persist behind a 400ms debounce; reloading before it flushes drops
|
||||
// the write and looks exactly like a persistence bug.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate((panelId) => {
|
||||
const raw = localStorage.getItem('DASHBOARD_V2_PANEL_COLUMN_WIDTHS');
|
||||
const widths = raw ? JSON.parse(raw) : {};
|
||||
return widths?.[panelId]?.['service.name'] ?? 0;
|
||||
}, SINGLE_PANEL_ID),
|
||||
)
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
const widened = (await header.boundingBox())?.width ?? 0;
|
||||
await page.reload();
|
||||
await expect(root.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await header.boundingBox())?.width ?? 0)
|
||||
.toBeCloseTo(widened, -1);
|
||||
});
|
||||
|
||||
test('TC-05 the List pager advances and re-queries', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRangeSequence(page, [
|
||||
QueryRange.raw(LOG_ROWS),
|
||||
QueryRange.raw(LOG_ROWS_PAGE_TWO),
|
||||
]);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
|
||||
|
||||
const root = panelRoot(page, SINGLE_PANEL_ID);
|
||||
await root.scrollIntoViewIfNeeded();
|
||||
await expect(root.getByTestId('list-panel-renderer')).toBeVisible();
|
||||
await expect(listPager.page(page, SINGLE_PANEL_ID)).toHaveText('Page 1');
|
||||
await expect(root.getByText('page-one line 0')).toBeVisible();
|
||||
|
||||
// Server-side: Next must issue a new query.
|
||||
const nextQuery = page.waitForRequest((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await listPager.next(page, SINGLE_PANEL_ID).click();
|
||||
await nextQuery;
|
||||
|
||||
await expect(listPager.page(page, SINGLE_PANEL_ID)).toHaveText('Page 2');
|
||||
await expect(root.getByText('page-two line 0')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 Previous is disabled on the first page', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(page, QueryRange.raw(LOG_ROWS));
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
|
||||
|
||||
const root = panelRoot(page, SINGLE_PANEL_ID);
|
||||
await root.scrollIntoViewIfNeeded();
|
||||
await expect(root.getByTestId('list-panel-renderer')).toBeVisible();
|
||||
await expect(listPager.prev(page, SINGLE_PANEL_ID)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('TC-07 changing the page size re-queries', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(page, QueryRange.raw(LOG_ROWS));
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
|
||||
|
||||
const root = panelRoot(page, SINGLE_PANEL_ID);
|
||||
await root.scrollIntoViewIfNeeded();
|
||||
await expect(root.getByTestId('list-panel-renderer')).toBeVisible();
|
||||
|
||||
const resize = page.waitForRequest((r) => r.url().includes('/query_range'));
|
||||
await listPager.pageSize(page, SINGLE_PANEL_ID).click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText('50 / page')
|
||||
.click();
|
||||
await resize;
|
||||
|
||||
// Page size resets to page 1.
|
||||
await expect(listPager.page(page, SINGLE_PANEL_ID)).toHaveText('Page 1');
|
||||
});
|
||||
});
|
||||
259
tests/e2e/tests/dashboards/v2/panels/57-drilldown.spec.ts
Normal file
259
tests/e2e/tests/dashboards/v2/panels/57-drilldown.spec.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test, type SeedApi } from '../../../../fixtures/dashboards';
|
||||
import { PanelKind, metricsQuery } from '../../../../helpers/dashboard-v2-spec';
|
||||
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
boundingBoxOf,
|
||||
contextMenu,
|
||||
drilldownItem,
|
||||
panelChart,
|
||||
panelRoot,
|
||||
} from '../../../../helpers/panels-v2';
|
||||
import {
|
||||
QueryRange,
|
||||
mockQueryRange,
|
||||
ramp,
|
||||
} from '../../../../helpers/query-range-mock';
|
||||
import {
|
||||
QUERY_TYPE_PANELS,
|
||||
SINGLE_PANEL_ID,
|
||||
VARIABLE_NAMES,
|
||||
VARIABLE_PANEL_ID,
|
||||
queryTypesDashboard,
|
||||
singlePanelDashboard,
|
||||
variablesDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the drilldown ContextMenu — items, navigation, and the kinds/query
|
||||
// types deliberately excluded. Mocked so a click lands on a known series.
|
||||
|
||||
async function openChartDrilldown(page: Page, panelId: string): Promise<void> {
|
||||
const box = await boundingBoxOf(panelChart(page, panelId), 'the chart');
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await expect(contextMenu(page)).toBeVisible();
|
||||
}
|
||||
|
||||
async function seedChartPanel(
|
||||
page: Page,
|
||||
dashboards: SeedApi,
|
||||
): Promise<string> {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]),
|
||||
);
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await expect(
|
||||
panelRoot(page, SINGLE_PANEL_ID).getByTestId('time-series-renderer'),
|
||||
).toBeVisible();
|
||||
return id;
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — panel drilldown', () => {
|
||||
test('TC-01 clicking a series opens the aggregate menu', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await seedChartPanel(page, dashboards);
|
||||
await openChartDrilldown(page, SINGLE_PANEL_ID);
|
||||
|
||||
await expect(drilldownItem(page, 'drilldown-view-logs')).toBeVisible();
|
||||
await expect(drilldownItem(page, 'drilldown-view-traces')).toBeVisible();
|
||||
await expect(drilldownItem(page, 'drilldown-breakout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 clicking the backdrop closes the menu', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await seedChartPanel(page, dashboards);
|
||||
await openChartDrilldown(page, SINGLE_PANEL_ID);
|
||||
|
||||
// Backdrop click. Escape only works when the backdrop holds focus.
|
||||
await page.locator('.context-menu-backdrop').click();
|
||||
await expect(contextMenu(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 View in Logs navigates to the logs explorer', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await seedChartPanel(page, dashboards);
|
||||
await openChartDrilldown(page, SINGLE_PANEL_ID);
|
||||
|
||||
// Disabled while the drilldown query resolves.
|
||||
const viewLogs = drilldownItem(page, 'drilldown-view-logs');
|
||||
await expect(viewLogs).toBeEnabled();
|
||||
|
||||
// safeNavigate uses `{ newTab: true }`, so the dashboard stays put.
|
||||
const popup = page.context().waitForEvent('page');
|
||||
await viewLogs.click();
|
||||
const logsTab = await popup;
|
||||
|
||||
await logsTab.waitForURL(/\/logs\/logs-explorer/);
|
||||
// The clicked series carries across as a composite query.
|
||||
expect(
|
||||
new URL(logsTab.url()).searchParams.get('compositeQuery'),
|
||||
).toBeTruthy();
|
||||
await logsTab.close();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\//);
|
||||
});
|
||||
|
||||
test('TC-04 Breakout opens a submenu and the back arrow returns', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await seedChartPanel(page, dashboards);
|
||||
await openChartDrilldown(page, SINGLE_PANEL_ID);
|
||||
|
||||
await drilldownItem(page, 'drilldown-breakout').click();
|
||||
const back = page.getByTestId('drilldown-breakout-back');
|
||||
await expect(back).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder('Search breakout options...'),
|
||||
).toBeVisible();
|
||||
|
||||
// Back returns to the aggregate menu.
|
||||
await back.click();
|
||||
await expect(drilldownItem(page, 'drilldown-view-logs')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 the Dashboard Variables submenu offers set and create', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]),
|
||||
);
|
||||
await dashboards.seedAndOpen(variablesDashboard());
|
||||
await expect(
|
||||
panelRoot(page, VARIABLE_PANEL_ID).getByTestId('time-series-renderer'),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(page, VARIABLE_PANEL_ID);
|
||||
await drilldownItem(page, 'drilldown-dashboard-variables').click();
|
||||
|
||||
// `service.name` is grouped-by and already has a variable, so Set is offered.
|
||||
await expect(drilldownItem(page, 'drilldown-var-set')).toBeVisible();
|
||||
await expect(page.getByTestId('drilldown-var-back')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 setting a variable from the menu updates the variables bar', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]),
|
||||
);
|
||||
await dashboards.seedAndOpen(variablesDashboard());
|
||||
await expect(
|
||||
panelRoot(page, VARIABLE_PANEL_ID).getByTestId('time-series-renderer'),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(page, VARIABLE_PANEL_ID);
|
||||
await drilldownItem(page, 'drilldown-dashboard-variables').click();
|
||||
await drilldownItem(page, 'drilldown-var-set').click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`variable-${VARIABLE_NAMES.custom}`),
|
||||
).toContainText('adservice');
|
||||
});
|
||||
|
||||
test('TC-07 creating a variable from the menu patches the dashboard spec', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.timeSeries([
|
||||
{
|
||||
labels: { 'k8s.namespace.name': 'signoz-adservice' },
|
||||
points: ramp(12, 2, 8),
|
||||
},
|
||||
]),
|
||||
);
|
||||
// No matching variable for this field, so the menu offers Create.
|
||||
const id = await dashboards.seedAndOpen(
|
||||
variablesDashboard(
|
||||
undefined,
|
||||
metricsQuery({ groupBy: ['k8s.namespace.name'] }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
panelRoot(page, VARIABLE_PANEL_ID).getByTestId('time-series-renderer'),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(page, VARIABLE_PANEL_ID);
|
||||
await drilldownItem(page, 'drilldown-dashboard-variables').click();
|
||||
await drilldownItem(page, 'drilldown-var-create').click();
|
||||
|
||||
// Create persists a DYNAMIC variable into spec.variables.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
return after.spec.variables.some(
|
||||
(variable) => variable.spec.name === 'k8s.namespace.name',
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-08 kinds that do not declare drilldown open no menu', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]),
|
||||
);
|
||||
await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({ kind: PanelKind.Histogram }),
|
||||
);
|
||||
|
||||
const root = panelRoot(page, SINGLE_PANEL_ID);
|
||||
await expect(root.getByTestId('histogram-panel-renderer')).toBeVisible();
|
||||
|
||||
// Histogram sets `drilldown: false`.
|
||||
const box = await boundingBoxOf(
|
||||
root.getByTestId('uplot-main-div'),
|
||||
'the histogram chart',
|
||||
);
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await expect(contextMenu(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-09 a non-builder query opens no menu', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await mockQueryRange(
|
||||
page,
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]),
|
||||
);
|
||||
await dashboards.seedAndOpen(queryTypesDashboard());
|
||||
|
||||
const root = panelRoot(page, QUERY_TYPE_PANELS.promql);
|
||||
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
|
||||
|
||||
// Gated to QUERY_BUILDER queries.
|
||||
const box = await boundingBoxOf(
|
||||
root.getByTestId('uplot-main-div'),
|
||||
'the promql chart',
|
||||
);
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await expect(contextMenu(page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
184
tests/e2e/tests/dashboards/v2/panels/66-view-modal.spec.ts
Normal file
184
tests/e2e/tests/dashboards/v2/panels/66-view-modal.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import {
|
||||
getDashboardV2ViaApi,
|
||||
gotoDashboardV2,
|
||||
setDashboardLockedViaApi,
|
||||
} from '../../../../helpers/dashboards-v2';
|
||||
import { editor } from '../../../../helpers/panel-editor-v2';
|
||||
import {
|
||||
PanelAction,
|
||||
openViewModal,
|
||||
runPanelAction,
|
||||
} from '../../../../helpers/panels-v2';
|
||||
import {
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: the View modal, and its two-way handoff with the panel editor.
|
||||
//
|
||||
// Both directions carry LIVE, unsaved state — modal → editor via router state
|
||||
// (`editSpec`), editor → modal via sessionStorage + `compositeQuery`. Losing
|
||||
// either silently discards in-progress work, so TC-09/TC-10 assert the carried
|
||||
// state AND that nothing was persisted.
|
||||
|
||||
test.describe('Dashboards V2 — View modal', () => {
|
||||
test('TC-01 View opens the modal and reflects it in the URL', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
const modal = await openViewModal(page, SINGLE_PANEL_ID);
|
||||
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(page.getByTestId('view-panel-refresh')).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get('expandedWidgetId')).toBe(
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 the modal opens directly from a deep link', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await page.goto(
|
||||
`/dashboard/${id}?expandedWidgetId=${SINGLE_PANEL_ID}&graphType=graph`,
|
||||
);
|
||||
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 Refresh re-issues the query', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await openViewModal(page, SINGLE_PANEL_ID);
|
||||
|
||||
const refetch = page.waitForRequest((r) => r.url().includes('/query_range'));
|
||||
await page.getByTestId('view-panel-refresh').click();
|
||||
const request = await refetch;
|
||||
expect(request.method()).toBe('POST');
|
||||
});
|
||||
|
||||
test('TC-05 switching the panel type in the modal does not persist', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await openViewModal(page, SINGLE_PANEL_ID);
|
||||
|
||||
await page.getByTestId('view-panel-type-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText('Table', { exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 Switch to Edit Mode hands off to the panel editor', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await openViewModal(page, SINGLE_PANEL_ID);
|
||||
|
||||
await page.getByTestId('view-panel-switch-to-edit').click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}/panel/`));
|
||||
await expect(editor.root(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 a locked panel opens in View but offers no Switch to Edit', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
// `canSwitchToEdit = canEditDashboard && !isLocked`, but View itself is not
|
||||
// role-gated — so the modal opens and only the handoff button disappears.
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
await setDashboardLockedViaApi(page, id, true);
|
||||
await gotoDashboardV2(page, id);
|
||||
|
||||
await runPanelAction(page, SINGLE_PANEL_ID, PanelAction.view);
|
||||
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
|
||||
await expect(page.getByTestId('view-panel-switch-to-edit')).toHaveCount(0);
|
||||
await expect(page.getByTestId('view-panel-refresh')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 View → Edit → View round-trips with no changes', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({ panelName: 'Round trip' }),
|
||||
);
|
||||
await openViewModal(page, SINGLE_PANEL_ID);
|
||||
|
||||
await page.getByTestId('view-panel-switch-to-edit').click();
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}/panel/`));
|
||||
await expect(editor.title(page)).toHaveValue('Round trip');
|
||||
await expect(editor.unsavedBadge(page)).toHaveCount(0);
|
||||
|
||||
await editor.switchToView(page).click();
|
||||
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Round trip',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-09 an unsaved change in the modal carries into the editor', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await openViewModal(page, SINGLE_PANEL_ID);
|
||||
|
||||
await page.getByTestId('view-panel-type-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText('Table', { exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
|
||||
await page.getByTestId('view-panel-switch-to-edit').click();
|
||||
await page.waitForURL(new RegExp(`/dashboard/${id}/panel/`));
|
||||
|
||||
// Editor opens on the MODIFIED panel, and still nothing is committed.
|
||||
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-10 an unsaved change in the editor carries back into the modal', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.title(page).fill('Edited but not saved');
|
||||
await expect(editor.unsavedBadge(page)).toBeVisible();
|
||||
|
||||
await editor.switchToView(page).click();
|
||||
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
|
||||
await expect(page.getByRole('dialog')).toContainText('Edited but not saved');
|
||||
|
||||
const after = await getDashboardV2ViaApi(page, id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
});
|
||||
167
tests/e2e/tests/dashboards/v2/panels/75-layout.spec.ts
Normal file
167
tests/e2e/tests/dashboards/v2/panels/75-layout.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../../fixtures/dashboards';
|
||||
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
|
||||
import {
|
||||
boundingBoxOf,
|
||||
panelResizeHandle,
|
||||
panelRoot,
|
||||
} from '../../../../helpers/panels-v2';
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
compactDashboard,
|
||||
} from '../../../../testdata/v2/panels-dashboard';
|
||||
|
||||
// Scope: grid layout mutations — drag, resize, and the guarantee that the
|
||||
// header's action cluster never starts a drag.
|
||||
//
|
||||
// react-grid-layout exposes no testids; `.panel-drag-handle` and
|
||||
// `.react-resizable-handle` are the documented class contract it is configured
|
||||
// with (draggableHandle / draggableCancel in SectionGrid), so they're used
|
||||
// directly here.
|
||||
|
||||
/** Grid geometry for one panel, read from the persisted spec. */
|
||||
async function gridItemOf(
|
||||
page: Page,
|
||||
dashboardId: string,
|
||||
panelId: string,
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const dashboard = await getDashboardV2ViaApi(page, dashboardId);
|
||||
for (const layout of dashboard.spec.layouts) {
|
||||
const item = layout.spec.items.find(
|
||||
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
|
||||
);
|
||||
if (item) {
|
||||
return {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`no grid item for panel ${panelId}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — grid layout', () => {
|
||||
test('TC-01 resizing a panel persists its new size', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const root = panelRoot(page, COMPACT_PANELS.timeseries);
|
||||
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
|
||||
const before = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
|
||||
const handle = await boundingBoxOf(
|
||||
panelResizeHandle(page, COMPACT_PANELS.timeseries),
|
||||
'the resize handle',
|
||||
);
|
||||
await page.mouse.move(
|
||||
handle.x + handle.width / 2,
|
||||
handle.y + handle.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x + 60, handle.y + 90, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
// The grid persists on resize-stop, so the spec is the source of truth
|
||||
// rather than the rendered pixel size.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
return after.height > before.height || after.width > before.width;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-02 a resized layout survives a reload', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const root = panelRoot(page, COMPACT_PANELS.timeseries);
|
||||
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
|
||||
|
||||
const handle = await boundingBoxOf(
|
||||
panelResizeHandle(page, COMPACT_PANELS.timeseries),
|
||||
'the resize handle',
|
||||
);
|
||||
await page.mouse.move(
|
||||
handle.x + handle.width / 2,
|
||||
handle.y + handle.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x, handle.y + 90, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const item = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
return item.height;
|
||||
})
|
||||
.toBeGreaterThan(6);
|
||||
|
||||
const persisted = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
await page.reload();
|
||||
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
|
||||
expect(await gridItemOf(page, id, COMPACT_PANELS.timeseries)).toEqual(
|
||||
persisted,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 opening the actions menu does not start a drag', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const root = panelRoot(page, COMPACT_PANELS.timeseries);
|
||||
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
|
||||
const before = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
|
||||
// The ⋮ button sits inside the drag handle but is marked `panel-no-drag`
|
||||
// and stops pointerdown, so opening the menu must leave the grid alone.
|
||||
await root.hover();
|
||||
await page.getByTestId(`panel-actions-${COMPACT_PANELS.timeseries}`).click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
expect(await gridItemOf(page, id, COMPACT_PANELS.timeseries)).toEqual(before);
|
||||
});
|
||||
|
||||
test('TC-04 dragging by the header moves the panel within its section', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const root = panelRoot(page, COMPACT_PANELS.timeseries);
|
||||
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
|
||||
const before = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
|
||||
const handle = await boundingBoxOf(
|
||||
root.locator('.panel-drag-handle').first(),
|
||||
'the drag handle',
|
||||
);
|
||||
await page.mouse.move(
|
||||
handle.x + handle.width / 4,
|
||||
handle.y + handle.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
// Drag a full tile-width right so the swap is unambiguous.
|
||||
await page.mouse.move(handle.x + handle.width, handle.y + handle.height / 2, {
|
||||
steps: 15,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
|
||||
return after.x !== before.x || after.y !== before.y;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user