import { Page, Locator } from '@playwright/test'; import { test, expect, E2ESelectorGroups } from '@grafana/plugin-e2e'; import { getCell, getCellHeight, getColumnIdx } from './table-utils'; const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb'; test.use({ viewport: { width: 2000, height: 1080 } }); // helper utils const waitForTableLoad = async (loc: Page | Locator) => { await expect(loc.locator('.rdg')).toBeVisible(); }; const disableAllTextWrap = async (loc: Page | Locator, selectors: E2ESelectorGroups) => { // disable text wrapping for all of the columns, since long text with links in them can push the links off the screen. const wrapTextToggle = loc.getByLabel('Wrap text'); const count = await wrapTextToggle.count(); for (let i = 0; i < count; i++) { const toggle = wrapTextToggle.nth(i); if ((await toggle.getAttribute('checked')) !== null) { await toggle.click({ force: true }); } } }; test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] }, () => { test('Tests word wrap, hover overflow, max cell height, and cell inspect', async ({ gotoDashboardPage, selectors, page, }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); // to avoid a race condition when counting up , wait for react-data-grid to finish rendering. await waitForTableLoad(page); const longTextColIdx = await getColumnIdx(page, 'Long Text'); // text wrapping is enabled by default on this panel. await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); // set a max row height, watch the height decrease, then clear it to continue. const maxRowHeightInput = page.getByLabel('Max row height').last(); await maxRowHeightInput.fill('80'); await expect(async () => { await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); }).toPass(); await maxRowHeightInput.clear(); // toggle the lorem ipsum column's wrap text toggle and confirm that the height shrinks. await dashboardPage .getByGrafanaSelector(selectors.components.OptionsGroup.group('panel-options-override-12')) .getByLabel('Wrap text') .click({ force: true }); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // test that hover overflow works. const loremIpsumCell = getCell(page, 1, longTextColIdx); await loremIpsumCell.scrollIntoViewIfNeeded(); await loremIpsumCell.hover(); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); await getCell(page, 1, longTextColIdx + 1).hover(); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // enable cell inspect, confirm that hover no longer triggers. await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Cell options Cell value inspect')) .first() .getByRole('switch', { name: 'Cell value inspect' }) .click({ force: true }); await loremIpsumCell.hover(); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // click cell inspect, check that cell inspection pops open in the side as we'd expect. const loremIpsumText = await loremIpsumCell.textContent(); expect(loremIpsumText).toBeDefined(); await loremIpsumCell.getByLabel('Inspect value').click(); await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); }); test('Tests visibility and display name via overrides', async ({ gotoDashboardPage, selectors, page }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); const stateOverride = dashboardPage.getByGrafanaSelector( selectors.components.OptionsGroup.group('panel-options-override-11') ); // confirm that "State" column is hidden by default. expect(page.getByRole('row').nth(0)).not.toContainText('State'); // toggle the "State" column visibility via the override we set up in the kitchen sink panel. const hideStateColumnSwitch = stateOverride.locator('label').last(); await hideStateColumnSwitch.click(); expect(page.getByRole('row').nth(0)).toContainText('State'); // now change the display name of the "State" column. const displayNameInput = stateOverride.locator('input[value="State"]').last(); await displayNameInput.fill('State (renamed)'); await displayNameInput.press('Enter'); expect(page.getByRole('row').nth(0)).toContainText('State (renamed)'); // toggle the "State" column visibility again to hide it again. this confirms that we avoid bugs related to // array lengths between the fields array and the column widths array. await hideStateColumnSwitch.click(); expect(page.getByRole('row').nth(0)).not.toContainText('State'); // since the previous assertion is just for the absence of text, let's also confirm that the table is // actually still on the page and that an error has not been throw. await waitForTableLoad(page); }); // we test niche cases for sorting, filtering, pagination, etc. in a unit tests already. // we mainly want to test the happiest paths for these in e2es as well to check for integration // issues, but the unit tests can confirm that the internal logic works as expected much more quickly and thoroughly. // hashtag testing pyramid. test('Tests sorting by column', async ({ gotoDashboardPage, selectors, page }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); // click the "State" column header to sort it. const stateColumnHeader = getCell(page, 0, 1); await stateColumnHeader.getByText('Info').click(); await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'ascending'); await expect(getCell(page, 1, 1)).toContainText('down'); // down or down fast await stateColumnHeader.getByText('Info').click(); await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'descending'); await expect(getCell(page, 1, 1)).toContainText('up'); // up or up fast await stateColumnHeader.getByText('Info').click(); await expect(stateColumnHeader).not.toHaveAttribute('aria-sort'); }); test('Tests filtering within a column', async ({ gotoDashboardPage, selectors, page }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); await waitForTableLoad(page); const infoColumnIdx = await getColumnIdx(page, 'Info'); const stateColumnHeader = page.getByRole('columnheader').nth(infoColumnIdx); // get the first value in the "State" column, filter it out, then check that it went away. const firstStateValue = (await getCell(page, 1, infoColumnIdx).textContent())!; await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click(); const filterContainer = dashboardPage.getByGrafanaSelector( selectors.components.Panels.Visualization.TableNG.Filters.Container ); await expect(filterContainer).toBeVisible(); // select all, then click the first value to unselect it, filtering it out. await filterContainer.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.SelectAll).click(); await filterContainer.getByTitle(firstStateValue, { exact: true }).locator('label').click(); await filterContainer.getByRole('button', { name: 'Ok' }).click(); // make sure the filter container closed when we clicked "Ok". await expect(filterContainer).not.toBeVisible(); // did it actually filter out our value? await expect(getCell(page, 1, infoColumnIdx)).not.toHaveText(firstStateValue); }); test('Tests pagination, row height adjustment', async ({ gotoDashboardPage, selectors, page }) => { const rowRe = /([\d]+) - ([\d]+) of ([\d]+) rows/; const getRowStatus = async (page: Page | Locator) => { const text = (await page.getByText(rowRe).textContent()) ?? ''; const match = text.match(rowRe); return { start: parseInt(match?.[1] ?? '0', 10), end: parseInt(match?.[2] ?? '0', 10), total: parseInt(match?.[3] ?? '0', 10), }; }; const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); await page .getByLabel(selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`), { exact: true }) .click(); // because of text wrapping, we're guaranteed to only be showing a single row when we enable pagination. await expect(page.getByText(/([\d]+) - ([\d]+) of ([\d]+) rows/)).toBeVisible(); await disableAllTextWrap(page, selectors); // any number of rows that is not "1" is allowed here, we don't want to police the exact number of rows that // are rendered since there are tons of factors which could effect this. we do want to grab this number for comparison // in a second, though. const smallRowStatus = await getRowStatus(page); expect(smallRowStatus.end).toBeGreaterThan(1); expect(page.getByRole('grid').getByRole('row')).toHaveCount(smallRowStatus.end + 2); // +2 for header and footer rows // change cell height to Large await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Table Cell height')) .locator('input') .last() .click(); const largeRowStatus = await getRowStatus(page); expect(largeRowStatus.end).toBeLessThan(smallRowStatus.end); expect(page.getByRole('grid').getByRole('row')).toHaveCount(largeRowStatus.end + 2); // +2 for header and footer rows // click a page over with the directional nav await page.getByLabel('next page').click(); const nextPageStatus = await getRowStatus(page); expect(nextPageStatus.start).toBe(largeRowStatus.end + 1); expect(nextPageStatus.end).toBe(largeRowStatus.end * 2); expect(nextPageStatus.total).toBe(largeRowStatus.total); // click a page number await page.getByTestId('data-testid panel content').getByRole('navigation').getByText('4', { exact: true }).click(); const fourthPageStatus = await getRowStatus(page); expect(fourthPageStatus.start).toBe(largeRowStatus.end * 3 + 1); expect(fourthPageStatus.end).toBe(largeRowStatus.end * 4); expect(fourthPageStatus.total).toBe(largeRowStatus.total); }); test.skip('Tests DataLinks (single and multi) and actions', async ({ gotoDashboardPage, selectors, page }) => { const addDataLink = async (title: string, url: string) => { await dashboardPage .getByGrafanaSelector( selectors.components.PanelEditor.OptionsPane.fieldLabel('Data links and actions Data links') ) .locator('button') .filter({ hasText: 'Add link' }) .click(); // DataLinks dialog has popped open - fill it in and add a global datalink. await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('dialog').locator('#link-title').fill(title); await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').focus(); await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').fill(url); await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').blur(); await page.getByRole('dialog').locator('button[aria-disabled="false"]').filter({ hasText: 'Save' }).click(); await expect(page.getByRole('dialog')).not.toBeVisible(); }; const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); await disableAllTextWrap(page, selectors); const infoColumnIdx = await getColumnIdx(page, 'Info'); const pillColIdx = await getColumnIdx(page, 'Pills'); const dataLinkColIdx = await getColumnIdx(page, 'Data Link'); // Info column has a single DataLink by default. const infoCell = getCell(page, 1, infoColumnIdx); await expect(infoCell.locator('a')).toBeVisible(); expect(infoCell.locator('a')).toHaveAttribute('href'); expect(infoCell.locator('a')).not.toHaveAttribute('aria-haspopup'); // now, add a DataLink to the whole table await addDataLink('Test link', 'https://grafana.com'); // add a DataLink to the whole table, all cells will now have a single link. const colCount = await page.getByRole('row').nth(1).getByRole('gridcell').count(); for (let colIdx = 0; colIdx < colCount; colIdx++) { // - pills column currently does not support DataLinks. // - we don't apply DataLinks to the DataLinks column itself, since they're rendered inside. if (colIdx === pillColIdx || colIdx === dataLinkColIdx) { continue; } const cell = getCell(page, 1, colIdx); await expect(cell.locator('a')).toBeVisible(); expect(cell.locator('a')).toHaveAttribute('href'); expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); } const headerContainer = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer); // add another data link. now we'll check that the multi-link popups work. await addDataLink('Another test link', 'https://grafana.com/foo'); // loop thru the columns, click the links, observe that the tooltip appears, and close the tooltip. for (let colIdx = 0; colIdx < colCount; colIdx++) { const cell = getCell(page, 1, colIdx); if (colIdx === infoColumnIdx) { // the Info column should still have its single link. expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); continue; } // - pills column currently does not support DataLinks. // - we don't apply DataLinks to the DataLinks column itself, since they're rendered inside. if (colIdx === pillColIdx || colIdx === dataLinkColIdx) { continue; } await cell.locator('a').click({ force: true }); await expect(page.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).toBeVisible(); await headerContainer.click(); // convenient just to click the header to close the tooltip. await expect(page.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeVisible(); } // add an Action to the whole table and check that the action button is added to the tooltip. // TODO -- saving for another day. }); test('Tests nested table expansion', async ({ gotoDashboardPage, selectors, page }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '4' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Nested tables')) ).toBeVisible(); await waitForTableLoad(page); await expect(page.locator('[role="row"]')).toHaveCount(3); // header + 2 rows const firstRowExpander = dashboardPage .getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.RowExpander) .first(); await firstRowExpander.click(); await expect(page.locator('[role="row"]')).not.toHaveCount(3); // more rows are present now, it is dynamic tho. // TODO: test sorting await firstRowExpander.click(); await expect(page.locator('[role="row"]')).toHaveCount(3); // back to original state }); test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); const firstCaret = dashboardPage .getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Caret) .first(); // test hovering over and blurring the caret, and whether the tooltip appears and disappears as expected. await firstCaret.hover(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).toBeVisible(); await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).hover(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).not.toBeVisible(); // when a pinned tooltip is open, clicking outside of it should close it. await firstCaret.click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).toBeVisible(); await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).not.toBeVisible(); // when a pinned tooltip is open, clicking inside of it should NOT close it. await firstCaret.click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).toBeVisible(); const tooltip = dashboardPage.getByGrafanaSelector( selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper ); await tooltip.click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).toBeVisible(); await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) ).not.toBeVisible(); }); test('Styling overrides with styling from field', async ({ gotoDashboardPage, selectors, page }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '1' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); await waitForTableLoad(page); const infoColumnIdx = await getColumnIdx(page, 'Info'); const dataLinkColumnIdx = await getColumnIdx(page, 'Data Link'); const stateColumnHeader = page.getByRole('columnheader').nth(infoColumnIdx); // filter to only "Up," which we have a style override on. await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click(); const filterContainer = dashboardPage.getByGrafanaSelector( selectors.components.Panels.Visualization.TableNG.Filters.Container ); await expect(filterContainer).toBeVisible(); await filterContainer.getByTitle('up', { exact: true }).locator('label').click(); await filterContainer.getByRole('button', { name: 'Ok' }).click(); const cell = getCell(page, 1, dataLinkColumnIdx); await expect(cell).toBeVisible(); await expect(cell).toHaveCSS('text-decoration', /line-through/); // now filter out "up," and confirm that the style override isn't present. await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click(); await expect(filterContainer).toBeVisible(); // select all, then click the first value to unselect it, filtering it out. await filterContainer.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.SelectAll).click(); await filterContainer.getByTitle('up', { exact: true }).locator('label').click(); await filterContainer.getByRole('button', { name: 'Ok' }).click(); await expect(cell).toBeVisible(); await expect(cell).not.toHaveCSS('text-decoration', /line-through/); }); test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, queryParams: new URLSearchParams({ editPanel: '3' }), }); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage) ).toBeVisible(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).not.toBeVisible(); }); });