diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 48bbb67e44d..44bf60ce1c2 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -464,6 +464,7 @@
/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-markdown.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-sparkline.spec.ts @grafana/dataviz-squad
+/e2e-playwright/panels-suite/table-utils.ts @grafana/dataviz-squad
/e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources
/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend
/e2e-playwright/smoke-tests-suite/ @grafana/grafana-frontend-platform
diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md
index b4c84011e3b..9b26c8a693e 100644
--- a/docs/sources/panels-visualizations/visualizations/table/index.md
+++ b/docs/sources/panels-visualizations/visualizations/table/index.md
@@ -204,6 +204,7 @@ This option is only available when you're editing the panel.
| Show table header | Show or hide column names imported from your data source. |
| Frozen columns | Freeze columns starting from the left side of the table. Enter a value to set how many columns are frozen. |
| Cell height | Set the height of the cell. Choose from **Small**, **Medium**, or **Large**. |
+| Max row height | Define the maximum height for a row in the table. This can be useful when **Wrap text** is enabled for one or more columns. |
| Enable pagination | Toggle the switch to control how many table rows are visible at once. When switched on, the page size automatically adjusts to the height of the table. This option doesn't affect queries. |
| Minimum column width | Define the lower limit of the column width, in pixels. By default, the minimum width of the table column is 150 pixels. For small-screen devices, such as mobile phones or tablets, reduce the value to `50` to allow table-based panels to render correctly in dashboards. |
| Column width | Define a column width, in pixels, rather than allowing the width to be set automatically. By default, Grafana calculates the column width based on the table size and the minimum column width. |
diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts
index c0efd234599..81e207bec9e 100644
--- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts
+++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts
@@ -2,6 +2,8 @@ import { Page, Locator } from '@playwright/test';
import { test, expect, E2ESelectorGroups } from '@grafana/plugin-e2e';
+import { getCell, getCellHeight } from './table-utils';
+
const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb';
test.use({ viewport: { width: 2000, height: 1080 } });
@@ -11,18 +13,6 @@ const waitForTableLoad = async (loc: Page | Locator) => {
await expect(loc.locator('.rdg')).toBeVisible();
};
-const getCell = async (loc: Page | Locator, rowIdx: number, colIdx: number) =>
- loc
- .getByRole('row')
- .nth(rowIdx)
- .getByRole(rowIdx === 0 ? 'columnheader' : 'gridcell')
- .nth(colIdx);
-
-const getCellHeight = async (loc: Page | Locator, rowIdx: number, colIdx: number) => {
- const cell = await getCell(loc, rowIdx, colIdx);
- return (await cell.boundingBox())?.height ?? 0;
-};
-
const getColumnIdx = async (loc: Page | Locator, columnName: string) => {
// find the index of the column "Long text." The kitchen sink table will change over time, but
// we can just find the column programatically and use it throughout the test.
@@ -55,7 +45,11 @@ const disableAllTextWrap = async (loc: Page | Locator, selectors: E2ESelectorGro
};
test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] }, () => {
- test('Tests word wrap, hover overflow, and cell inspect', async ({ gotoDashboardPage, selectors, page }) => {
+ 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' }),
@@ -73,10 +67,19 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
// 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'))
- .getByText('Wrap text')
- .click();
+ .getByLabel('Wrap text')
+ .click({ force: true });
await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100);
// test that hover overflow works.
diff --git a/e2e-playwright/panels-suite/table-markdown.spec.ts b/e2e-playwright/panels-suite/table-markdown.spec.ts
index 68068271fe6..02295359cf6 100644
--- a/e2e-playwright/panels-suite/table-markdown.spec.ts
+++ b/e2e-playwright/panels-suite/table-markdown.spec.ts
@@ -1,9 +1,13 @@
import { test, expect } from '@grafana/plugin-e2e';
+import { getCellHeight } from './table-utils';
+
test.use({
viewport: { width: 1280, height: 1080 },
});
+const MARKDOWN_DASHBOARD_UID = '2769f5d8-0094-4ac4-a4f0-f68f620339cc';
+
test.describe(
'Panels test: Table - Markdown',
{
@@ -12,11 +16,28 @@ test.describe(
() => {
test('Tests Markdown tables are successfully rendered', async ({ gotoDashboardPage, page }) => {
await gotoDashboardPage({
- uid: '2769f5d8-0094-4ac4-a4f0-f68f620339cc',
+ uid: MARKDOWN_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '1' }),
});
await expect(page.getByRole('grid')).toBeVisible();
});
+
+ test('Tests dynamic height and max row height', async ({ gotoDashboardPage, page }) => {
+ await gotoDashboardPage({
+ uid: MARKDOWN_DASHBOARD_UID,
+ queryParams: new URLSearchParams({ editPanel: '1' }),
+ });
+
+ // confirm that the second row of the table is tall due to the content in it
+ await expect(getCellHeight(page, 2, 1)).resolves.toBeGreaterThan(100);
+
+ // set the max row height to 80, watch the row shrink
+ const maxRowHeightInput = page.getByLabel('Max row height').last();
+ await maxRowHeightInput.fill('80');
+ await expect(async () => {
+ await expect(getCellHeight(page, 2, 1)).resolves.toBeLessThan(100);
+ }).toPass();
+ });
}
);
diff --git a/e2e-playwright/panels-suite/table-utils.ts b/e2e-playwright/panels-suite/table-utils.ts
new file mode 100644
index 00000000000..56e04597bda
--- /dev/null
+++ b/e2e-playwright/panels-suite/table-utils.ts
@@ -0,0 +1,13 @@
+import { Page, Locator } from '@playwright/test';
+
+export const getCell = async (loc: Page | Locator, rowIdx: number, colIdx: number) =>
+ loc
+ .getByRole('row')
+ .nth(rowIdx)
+ .getByRole(rowIdx === 0 ? 'columnheader' : 'gridcell')
+ .nth(colIdx);
+
+export const getCellHeight = async (loc: Page | Locator, rowIdx: number, colIdx: number) => {
+ const cell = await getCell(loc, rowIdx, colIdx);
+ return (await cell.boundingBox())?.height ?? 0;
+};
diff --git a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts
index 1aa6ed61a7e..f287e4ab245 100644
--- a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts
@@ -31,6 +31,10 @@ export interface Options {
frozenColumns?: {
left?: number;
};
+ /**
+ * limits the maximum height of a row, if text wrapping or dynamic height is enabled
+ */
+ maxRowHeight?: number;
/**
* Controls whether the panel should show the header
*/
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx
index a1e7aa55ec0..1f15495ac2a 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx
@@ -3,6 +3,8 @@ import { css } from '@emotion/css';
import { formattedValueToString } from '@grafana/data';
import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink';
+import { TABLE } from '../constants';
+import { getActiveCellSelector } from '../styles';
import { AutoCellProps, TableCellStyles } from '../types';
export function AutoCell({ value, field, rowIdx }: AutoCellProps) {
@@ -15,22 +17,37 @@ export function AutoCell({ value, field, rowIdx }: AutoCellProps) {
);
}
-export const getStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) =>
+export const getStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow, maxHeight }) =>
css({
...(textWrap && { whiteSpace: 'pre-line' }),
...(shouldOverflow && {
- '&:hover, &[aria-selected=true]': {
+ [getActiveCellSelector(Boolean(maxHeight))]: {
whiteSpace: 'pre-line',
},
}),
+ ...(maxHeight != null &&
+ textWrap && {
+ height: 'auto',
+ overflowY: 'hidden',
+ display: '-webkit-box',
+ WebkitBoxOrient: 'vertical',
+ WebkitLineClamp: Math.floor(maxHeight / TABLE.LINE_HEIGHT),
+ [getActiveCellSelector(true)]: {
+ display: 'flex',
+ WebkitLineClamp: 'none',
+ WebkitBoxOrient: 'unset',
+ overflowY: 'auto',
+ height: 'fit-content',
+ },
+ }),
});
-export const getJsonCellStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) =>
+export const getJsonCellStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow, maxHeight }) =>
css({
fontFamily: 'monospace',
...(textWrap && { whiteSpace: 'pre' }),
...(shouldOverflow && {
- '&:hover, &[aria-selected=true]': {
+ [getActiveCellSelector(Boolean(maxHeight))]: {
whiteSpace: 'pre',
},
}),
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.tsx
index 4f03afe2ea1..c0c6d707221 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.tsx
@@ -22,7 +22,7 @@ export const getStyles: TableCellStyles = (theme, { textWrap, textAlign }) =>
...(textWrap && {
flexDirection: 'column',
justifyContent: 'center',
- alignItems: getJustifyContent(textAlign),
+ alignItems: `${getJustifyContent(textAlign)} !important`, // we can't guarantee order, and alignItems is set on a sibling class.
}),
'> a': {
flexWrap: 'nowrap',
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx
index 4013f2f04f8..805374f4471 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx
@@ -18,7 +18,7 @@ export const ImageCell = ({ cellOptions, field, value, rowIdx }: ImageCellProps)
export const getStyles: TableCellStyles = () =>
css({
- 'a, img': {
+ '&, a, img': {
width: '100%',
height: '100%',
},
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx
index 625ab881976..32074a5e940 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx
@@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { renderMarkdown } from '@grafana/data';
import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink';
+import { getActiveCellSelector } from '../styles';
import { MarkdownCellProps, TableCellStyles } from '../types';
export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCellProps) {
@@ -25,9 +26,9 @@ export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCel
);
}
-export const getStyles: TableCellStyles = (theme) =>
+export const getStyles: TableCellStyles = (theme, { maxHeight }) =>
css({
- '&, &:hover, &[aria-selected=true]': {
+ [`&, ${getActiveCellSelector(Boolean(maxHeight))}`]: {
whiteSpace: 'normal',
},
diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx
index 9ab5e732d8d..baf3715eb77 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx
@@ -11,6 +11,7 @@ import {
} from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
+import { getActiveCellSelector } from '../styles';
import { PillCellProps, TableCellStyles, TableCellValue } from '../types';
export function PillCell({ rowIdx, field, theme, getTextColorForBackground }: PillCellProps) {
@@ -101,14 +102,14 @@ function getPillColor(value: unknown, field: Field, theme: GrafanaTheme2): strin
return getColorByStringHash(colors, String(value));
}
-export const getStyles: TableCellStyles = (theme, { textWrap, shouldOverflow }) =>
+export const getStyles: TableCellStyles = (theme, { textWrap, shouldOverflow, maxHeight }) =>
css({
display: 'inline-flex',
gap: theme.spacing(0.5),
flexWrap: textWrap ? 'wrap' : 'nowrap',
...(shouldOverflow && {
- '&:hover, &[aria-selected=true]': {
+ [getActiveCellSelector(Boolean(maxHeight))]: {
flexWrap: 'wrap',
},
}),
diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
index 39979cabfb3..50628538498 100644
--- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
+++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
@@ -61,6 +61,7 @@ import {
getGridStyles,
getHeaderCellStyles,
getLinkStyles,
+ getMaxHeightCellStyles,
getTooltipStyles,
} from './styles';
import {
@@ -112,6 +113,7 @@ export function TableNG(props: TableNGProps) {
getActions = () => [],
height,
initialSortBy,
+ maxRowHeight: _maxRowHeight,
noHeader,
onCellFilterAdded,
onColumnResize,
@@ -202,6 +204,8 @@ export function TableNG(props: TableNGProps) {
showTypeIcons: showTypeIcons ?? false,
typographyCtx,
});
+ // the minimum max row height we should honor is a single line of text.
+ const maxRowHeight = _maxRowHeight != null ? Math.max(TABLE.LINE_HEIGHT, _maxRowHeight) : undefined;
const rowHeight = useRowHeight({
columnWidths: widths,
fields: visibleFields,
@@ -209,6 +213,7 @@ export function TableNG(props: TableNGProps) {
defaultHeight: defaultRowHeight,
expandedRows,
typographyCtx,
+ maxHeight: maxRowHeight,
});
const {
@@ -414,17 +419,24 @@ export function TableNG(props: TableNGProps) {
? clsx('table-cell-actions', getCellActionStyles(theme, textAlign))
: undefined;
- const shouldOverflow = rowHeight !== 'auto' && shouldTextOverflow(field);
+ const shouldOverflow = rowHeight !== 'auto' && (shouldTextOverflow(field) || Boolean(maxRowHeight));
const textWrap = rowHeight === 'auto' || shouldTextWrap(field);
const withTooltip = withDataLinksActionsTooltip(field, cellType);
const canBeColorized = canFieldBeColorized(cellType, applyToRowBgFn);
- const cellStyleOptions: TableCellStyleOptions = { textAlign, textWrap, shouldOverflow };
+ const cellStyleOptions: TableCellStyleOptions = {
+ textAlign,
+ textWrap,
+ shouldOverflow,
+ maxHeight: maxRowHeight,
+ };
result.colsWithTooltip[displayName] = withTooltip;
const defaultCellStyles = getDefaultCellStyles(theme, cellStyleOptions);
const cellSpecificStyles = getCellSpecificStyles(cellType, field, theme, cellStyleOptions);
const linkStyles = getLinkStyles(theme, canBeColorized);
+ const cellParentStyles = clsx(defaultCellStyles, linkStyles);
+ const maxHeightClassName = maxRowHeight ? getMaxHeightCellStyles(theme, cellStyleOptions) : undefined;
// TODO: in future extend this to ensure a non-classic color scheme is set with AutoCell
@@ -457,7 +469,11 @@ export function TableNG(props: TableNGProps) {