From b22f15ad16511e1785055d1847e490bfdc33e540 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 29 Aug 2025 15:10:17 -0400 Subject: [PATCH] Table: Max row height for variable height rows (#109639) * Table: Max height for wrapped content * Docs: tableNG max cell height (#110069) Co-authored-by: Paul Marbach * change to Max row height instead of Max cell height * fix unit test * table utils codeowners * Update packages/grafana-ui/src/components/Table/TableNG/utils.ts Co-authored-by: Leon Sorokin * update docs * fix docs * Revert "fix unit test" This reverts commit c46b0f1bece893c9eb19f5a38e5d200a70df188b. * fix unit test * trade one important for another * Tweaked wording * hover overflow for max row height * get rid of commented out section * and we did it without important * centralize overflow for max height assessment * some alignment stuff was busted * didn't end up using the max heigh arg for shouldTextOverflow * make i18n path more consistent * put some tooltip things back since they ultimately didnt change * we can simplify the :not selector * delete comment * don't bother with :not --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Co-authored-by: Leon Sorokin --- .github/CODEOWNERS | 1 + .../visualizations/table/index.md | 1 + .../panels-suite/table-kitchenSink.spec.ts | 33 ++++++++------- .../panels-suite/table-markdown.spec.ts | 23 ++++++++++- e2e-playwright/panels-suite/table-utils.ts | 13 ++++++ .../panelcfg/x/TablePanelCfg_types.gen.ts | 4 ++ .../Table/TableNG/Cells/AutoCell.tsx | 25 ++++++++++-- .../Table/TableNG/Cells/DataLinksCell.tsx | 2 +- .../Table/TableNG/Cells/ImageCell.tsx | 2 +- .../Table/TableNG/Cells/MarkdownCell.tsx | 5 ++- .../Table/TableNG/Cells/PillCell.tsx | 5 ++- .../src/components/Table/TableNG/TableNG.tsx | 40 ++++++++++++++++--- .../TableNG/components/TableCellTooltip.tsx | 2 +- .../src/components/Table/TableNG/hooks.ts | 7 +++- .../src/components/Table/TableNG/styles.ts | 30 ++++++++++++-- .../src/components/Table/TableNG/types.ts | 2 + .../components/Table/TableNG/utils.test.ts | 17 ++++++++ .../src/components/Table/TableNG/utils.ts | 17 ++++++-- public/app/plugins/panel/table/TablePanel.tsx | 1 + public/app/plugins/panel/table/module.tsx | 9 +++++ public/app/plugins/panel/table/panelcfg.cue | 10 +++-- .../app/plugins/panel/table/panelcfg.gen.ts | 4 ++ public/locales/en-US/grafana.json | 2 + 23 files changed, 211 insertions(+), 44 deletions(-) create mode 100644 e2e-playwright/panels-suite/table-utils.ts 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) { ); @@ -475,7 +491,7 @@ export function TableNG(props: TableNGProps) { const height = rowHeightFn(props.row); const frame = data; - return ( + let result = ( <> ); + + if (maxRowHeight != null) { + result =
{result}
; + } + + return result; }; // renderCellContent fires second. @@ -520,10 +542,12 @@ export function TableNG(props: TableNGProps) { const tooltipDisplayName = getDisplayName(tooltipField); const tooltipCellOptions = getCellOptions(tooltipField); const tooltipFieldRenderer = getCellRenderer(tooltipField, tooltipCellOptions); + const tooltipCellStyleOptions = { textAlign: getAlignment(tooltipField), textWrap: shouldTextWrap(tooltipField), shouldOverflow: false, + maxHeight: maxRowHeight, } satisfies TableCellStyleOptions; const tooltipCanBeColorized = canFieldBeColorized(tooltipCellOptions.type, applyToRowBgFn); const tooltipDefaultStyles = getDefaultCellStyles(theme, tooltipCellStyleOptions); @@ -579,7 +603,12 @@ export function TableNG(props: TableNGProps) { } return ( - + {renderBasicCellContent(props)} ); @@ -639,6 +668,7 @@ export function TableNG(props: TableNGProps) { getCellColorInlineStyles, getTextColorForBackground, isCountRowsSet, + maxRowHeight, numFrozenColsFullyInView, onCellFilterAdded, rowHeight, diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx index 232f3bb01a5..660f85397f6 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx @@ -144,7 +144,7 @@ export const TableCellTooltip = memo( placement={placement} wrapperClassName={classes.tooltipWrapper} className={className} - style={{ ...style, minWidth: width, ...(!dynamicHeight && { height }) }} + style={{ ...style, width, ...(!dynamicHeight && { height }) }} referenceElement={cellElement} onMouseLeave={onMouseLeave} onMouseEnter={onMouseEnter} diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 7a1be693d8c..a7164ef9c70 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -403,6 +403,7 @@ interface UseRowHeightOptions { defaultHeight: NonNullable; expandedRows: Set; typographyCtx: TypographyCtx; + maxHeight?: number; } export function useRowHeight({ @@ -412,8 +413,12 @@ export function useRowHeight({ defaultHeight, expandedRows, typographyCtx, + maxHeight, }: UseRowHeightOptions): NonNullable | ((row: TableRow) => number) { - const measurers = useMemo(() => buildCellHeightMeasurers(fields, typographyCtx), [fields, typographyCtx]); + const measurers = useMemo( + () => buildCellHeightMeasurers(fields, typographyCtx, maxHeight), + [fields, typographyCtx, maxHeight] + ); const hasWrappedCols = useMemo(() => measurers?.length ?? 0 > 0, [measurers]); const colWidths = useMemo(() => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/styles.ts b/packages/grafana-ui/src/components/Table/TableNG/styles.ts index 1094108a587..f95f5464f29 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/styles.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/styles.ts @@ -49,7 +49,7 @@ export const getGridStyles = (theme: GrafanaTheme2, enablePagination?: boolean, // add a box shadow on hover and selection for all body cells '& > :not(.rdg-summary-row, .rdg-header-row) > .rdg-cell': { - '&:hover, &[aria-selected=true]': { boxShadow: theme.shadows.z2 }, + [getActiveCellSelector()]: { boxShadow: theme.shadows.z2 }, // selected cells should appear below hovered cells. '&:hover': { zIndex: theme.zIndex.tooltip - 7 }, '&[aria-selected=true]': { zIndex: theme.zIndex.tooltip - 6 }, @@ -128,14 +128,16 @@ export const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Proper '&:last-child': { borderInlineEnd: 'none' }, }); -export const getDefaultCellStyles: TableCellStyles = (theme, { textAlign, shouldOverflow }) => +export const getDefaultCellStyles: TableCellStyles = (theme, { textAlign, shouldOverflow, maxHeight }) => css({ display: 'flex', alignItems: 'center', textAlign, - justifyContent: getJustifyContent(textAlign), + justifyContent: Boolean(maxHeight) ? 'flex-start' : getJustifyContent(textAlign), + ...(maxHeight && { overflowY: 'hidden' }), ...(shouldOverflow && { minHeight: '100%' }), - '&:hover, &[aria-selected=true]': { + + [getActiveCellSelector()]: { '.table-cell-actions': { display: 'flex' }, ...(shouldOverflow && { zIndex: theme.zIndex.tooltip - 2, @@ -145,6 +147,21 @@ export const getDefaultCellStyles: TableCellStyles = (theme, { textAlign, should }, }); +export const getMaxHeightCellStyles: TableCellStyles = (_theme, { textAlign, maxHeight }) => + css({ + display: 'flex', + alignItems: 'center', + textAlign, + justifyContent: getJustifyContent(textAlign), + maxHeight, + width: '100%', + overflowY: 'hidden', + [getActiveCellSelector(true)]: { + maxHeight: 'none', + minHeight: '100%', + }, + }); + export const getCellActionStyles = (theme: GrafanaTheme2, textAlign: TextAlign) => css({ display: 'none', @@ -183,6 +200,8 @@ export const getTooltipStyles = (theme: GrafanaTheme2, textAlign: TextAlign) => tooltipContent: css({ height: '100%', width: '100%', + display: 'flex', + alignItems: 'center', }), tooltipWrapper: css({ background: theme.colors.background.primary, @@ -206,3 +225,6 @@ export const getTooltipStyles = (theme: GrafanaTheme2, textAlign: TextAlign) => }, }), }); + +export const getActiveCellSelector = (isNested?: boolean) => + isNested ? '.rdg-cell:hover &, [aria-selected=true] &' : '&:hover, &[aria-selected=true]'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 123a8f39e7d..608edfadb4d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -132,6 +132,7 @@ export interface BaseTableProps { frozenColumns?: number; enablePagination?: boolean; cellHeight?: TableCellHeight; + maxRowHeight?: number; structureRev?: number; transparent?: boolean; /** @alpha Used by SparklineCell when provided */ @@ -258,6 +259,7 @@ export interface TableCellStyleOptions { textWrap: boolean; textAlign: TextAlign; shouldOverflow: boolean; + maxHeight?: number; } export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOptions) => string; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index 0bc491d5733..6b15eb32b89 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -1158,6 +1158,23 @@ describe('TableNG utils', () => { const measurers = buildCellHeightMeasurers(fields, ctx); expect(measurers).toBeUndefined(); }); + + it('clamps by maxHeight if set', () => { + const fields: Field[] = [ + { + name: 'Tags', + type: FieldType.string, + values: ['tag1,tag2', 'tag3', '["tag4","tag5","tag6"]'], + config: { custom: { wrapText: true, cellOptions: { type: TableCellDisplayMode.Pill } } }, + }, + ]; + const measurers = buildCellHeightMeasurers(fields, ctx); + expect(measurers![0].measure!(fields[0].values[2], 20, fields[0], 2, 100)).toBeGreaterThan(50); + + fields[0].config!.custom!.maxHeight = 50; + const measurersWithMax = buildCellHeightMeasurers(fields, ctx, 50); + expect(measurersWithMax![0].measure!(fields[0].values[2], 20, fields[0], 2, 100)).toBe(50); + }); }); describe('getRowHeight', () => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 0e818b9eac2..ccff581c3c3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -84,6 +84,16 @@ export function shouldTextWrap(field: Field): boolean { return Boolean(field.config.custom?.wrapText); } +/** + * @internal wrap a cell height measurer to clamp its output to the maxHeight defined in the field, if any. + */ +function clampByMaxHeight(measurer: MeasureCellHeight, maxHeight = Infinity): MeasureCellHeight { + return (value, width, field, rowIdx, lineHeight) => { + const rawHeight = measurer(value, width, field, rowIdx, lineHeight); + return Math.min(rawHeight, maxHeight); + }; +} + /** * @internal creates a typography context based on a font size and family. used to measure text * and estimate size of text in cells. @@ -249,7 +259,8 @@ const spaceRegex = /[\s-]/; */ export function buildCellHeightMeasurers( fields: Field[], - typographyCtx: TypographyCtx + typographyCtx: TypographyCtx, + maxHeight?: number ): MeasureCellHeightEntry[] | undefined { const result: Record = {}; let wrappedFields = 0; @@ -279,8 +290,8 @@ export function buildCellHeightMeasurers( if (!result[measurerFactoryKey]) { const [measure, estimate] = measurerFactory[measurerFactoryKey](); result[measurerFactoryKey] = { - measure, - estimate, + measure: clampByMaxHeight(measure, maxHeight), + estimate: estimate != null ? clampByMaxHeight(estimate, maxHeight) : undefined, fieldIdxs: [], }; } diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index bf45bed58cd..850158055e4 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -80,6 +80,7 @@ export function TablePanel(props: Props) { frozenColumns={options.frozenColumns?.left} enablePagination={options.footer?.enablePagination} cellHeight={options.cellHeight} + maxRowHeight={options.maxRowHeight} timeRange={timeRange} enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair} fieldConfig={fieldConfig} diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 2dafbfba899..ec13cf9a2ff 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -198,6 +198,15 @@ export const plugin = new PanelPlugin(TablePanel) ], }, }) + .addNumberInput({ + path: 'maxRowHeight', + name: t('table.name-max-height', 'Max row height'), + category, + settings: { + placeholder: t('table.placeholder-max-height', 'none'), + min: 0, + }, + }) .addBooleanSwitch({ path: 'footer.show', category: footerCategory, diff --git a/public/app/plugins/panel/table/panelcfg.cue b/public/app/plugins/panel/table/panelcfg.cue index 1bfbd053cf2..29b44b42e85 100644 --- a/public/app/plugins/panel/table/panelcfg.cue +++ b/public/app/plugins/panel/table/panelcfg.cue @@ -44,10 +44,12 @@ composableKinds: PanelCfg: { } // Controls the height of the rows cellHeight?: ui.TableCellHeight & (*"sm" | _) - // Defines the number of columns to freeze on the left side of the table - frozenColumns?: { - left?: number | *0 - } + // limits the maximum height of a row, if text wrapping or dynamic height is enabled + maxRowHeight?: number + // Defines the number of columns to freeze on the left side of the table + frozenColumns?: { + left?: number | *0 + } } @cuetsy(kind="interface") FieldConfig: { ui.TableFieldOptions diff --git a/public/app/plugins/panel/table/panelcfg.gen.ts b/public/app/plugins/panel/table/panelcfg.gen.ts index dba9153ea0c..db1994b1c42 100644 --- a/public/app/plugins/panel/table/panelcfg.gen.ts +++ b/public/app/plugins/panel/table/panelcfg.gen.ts @@ -29,6 +29,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/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 855db903047..f5d9f8fae5b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12812,6 +12812,7 @@ "name-fields": "Fields", "name-frozen-columns": "Frozen columns", "name-hide-in-table": "Hide in table", + "name-max-height": "Max row height", "name-min-column-width": "Minimum column width", "name-show-table-footer": "Show table footer", "name-show-table-header": "Show table header", @@ -12821,6 +12822,7 @@ "name-wrap-text": "Wrap text", "placeholder-column-width": "auto", "placeholder-fields": "All Numeric Fields", + "placeholder-max-height": "none", "tooltip-placement-options": { "label-auto": "Auto", "label-bottom": "Bottom",