diff --git a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json index 2b9ab7817cf..44c37b2ae71 100644 --- a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json +++ b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json @@ -295,6 +295,10 @@ "wrapText": true } }, + { + "id": "custom.maxHeight", + "value": null + }, { "id": "custom.width", "value": 255 diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index c0efd234599..1a66796e84c 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -55,7 +55,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 +77,22 @@ 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); - await dashboardPage - .getByGrafanaSelector(selectors.components.OptionsGroup.group('panel-options-override-12')) - .getByText('Wrap text') - .click(); + // toggle the lorem ipsum column's wrap text toggle and confirm that the height shrinks. + const longTextFieldOverrides = dashboardPage.getByGrafanaSelector( + selectors.components.OptionsGroup.group('panel-options-override-12') + ); + + // TODO: we have added a null value for max height to the field overrides in the JSON, + // because there's no good way to add a field override in an e2e at this point. + const maxCellHeightInput = longTextFieldOverrides.getByLabel('Max cell height'); + + await maxCellHeightInput.fill('80'); + await expect(async () => { + await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); + }).toPass(); + await maxCellHeightInput.clear(); + + await longTextFieldOverrides.getByLabel('Wrap text').click({ force: true }); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // test that hover overflow works. diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 8d7c5f85bef..e847cd43c4f 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -1009,6 +1009,10 @@ export interface TableFieldOptions extends HideableFieldConfig { */ hideHeader?: boolean; inspect: boolean; + /** + * if set, limit the height in pixels that the wrapped text can flow to + */ + maxHeight?: number; minWidth?: number; /** * Selecting or hovering this field will show a tooltip containing the content within the target field diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 91def1fa980..8b96bad7aca 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -131,6 +131,8 @@ TableFieldOptions: { hideHeader?: bool // if true, wrap the text content of the cell wrapText?: bool + // if set, limit the height in pixels that the wrapped text can flow to + maxHeight?: number // Enables text wrapping for column headers wrapHeaderText?: bool // Selecting or hovering this field will show a tooltip containing the content within the target field 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..24774d9df91 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,7 @@ import { css } from '@emotion/css'; import { formattedValueToString } from '@grafana/data'; import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink'; +import { TABLE } from '../constants'; import { AutoCellProps, TableCellStyles } from '../types'; export function AutoCell({ value, field, rowIdx }: AutoCellProps) { @@ -15,7 +16,7 @@ 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 && { @@ -23,6 +24,14 @@ export const getStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) whiteSpace: 'pre-line', }, }), + ...(typeof maxHeight === 'number' && { + height: 'auto', + minHeight: 'none', + overflowY: 'hidden', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: Math.floor(maxHeight / TABLE.LINE_HEIGHT), + }), }); export const getJsonCellStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) => diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 39979cabfb3..75724676a4f 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -89,6 +89,7 @@ import { getDisplayName, getIsNestedTable, getJustifyContent, + getMaxHeight, getVisibleFields, isCellInspectEnabled, predicateByName, @@ -418,13 +419,15 @@ export function TableNG(props: TableNGProps) { const textWrap = rowHeight === 'auto' || shouldTextWrap(field); const withTooltip = withDataLinksActionsTooltip(field, cellType); const canBeColorized = canFieldBeColorized(cellType, applyToRowBgFn); - const cellStyleOptions: TableCellStyleOptions = { textAlign, textWrap, shouldOverflow }; + const maxHeight = getMaxHeight(field); + const cellStyleOptions: TableCellStyleOptions = { textAlign, textWrap, shouldOverflow, maxHeight }; 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, cellSpecificStyles, linkStyles); // TODO: in future extend this to ensure a non-classic color scheme is set with AutoCell @@ -457,7 +460,7 @@ export function TableNG(props: TableNGProps) { ); @@ -465,6 +468,12 @@ export function TableNG(props: TableNGProps) { result.cellRootRenderers[displayName] = renderCellRoot; + const clampByMaxHeight = (maxHeight: number, children: ReactNode, cellStyles: string) => ( +
+ {children} +
+ ); + const renderBasicCellContent = (props: RenderCellProps): JSX.Element => { const rowIdx = props.row.__index; const value = props.row[props.column.key]; @@ -475,7 +484,7 @@ export function TableNG(props: TableNGProps) { const height = rowHeightFn(props.row); const frame = data; - return ( + let result = ( <> ); + + if (maxHeight != null) { + result = clampByMaxHeight(maxHeight, result, cellParentStyles); + } + + return result; }; // renderCellContent fires second. @@ -519,11 +534,13 @@ export function TableNG(props: TableNGProps) { if (tooltipField) { const tooltipDisplayName = getDisplayName(tooltipField); const tooltipCellOptions = getCellOptions(tooltipField); - const tooltipFieldRenderer = getCellRenderer(tooltipField, tooltipCellOptions); + const tooltipMaxHeight = getMaxHeight(tooltipField); + const tooltipCellStyleOptions = { textAlign: getAlignment(tooltipField), textWrap: shouldTextWrap(tooltipField), shouldOverflow: false, + maxHeight: tooltipMaxHeight, } satisfies TableCellStyleOptions; const tooltipCanBeColorized = canFieldBeColorized(tooltipCellOptions.type, applyToRowBgFn); const tooltipDefaultStyles = getDefaultCellStyles(theme, tooltipCellStyleOptions); @@ -535,6 +552,14 @@ export function TableNG(props: TableNGProps) { ); const tooltipLinkStyles = getLinkStyles(theme, tooltipCanBeColorized); const tooltipClasses = getTooltipStyles(theme, textAlign); + const tooltipBodyClasses = clsx(tooltipDefaultStyles, tooltipSpecificStyles, tooltipLinkStyles); + + let tooltipFieldRenderer = getCellRenderer(tooltipField, tooltipCellOptions); + if (tooltipMaxHeight) { + const OrigCellRenderer = tooltipFieldRenderer; + tooltipFieldRenderer = (props) => + clampByMaxHeight(tooltipMaxHeight, , tooltipBodyClasses); + } const placement = field.config.custom?.tooltip?.placement ?? TableCellTooltipPlacement.Auto; const tooltipWidth = @@ -545,12 +570,7 @@ export function TableNG(props: TableNGProps) { const tooltipProps = { cellOptions: tooltipCellOptions, classes: tooltipClasses, - className: clsx( - tooltipClasses.tooltipContent, - tooltipDefaultStyles, - tooltipSpecificStyles, - tooltipLinkStyles - ), + className: clsx(tooltipClasses.tooltipContent, { [tooltipBodyClasses]: tooltipMaxHeight == null }), data, disableSanitizeHtml, field: tooltipField, @@ -579,7 +599,12 @@ export function TableNG(props: TableNGProps) { } return ( - + {renderBasicCellContent(props)} ); @@ -646,6 +671,7 @@ export function TableNG(props: TableNGProps) { rows, setFilter, showTypeIcons, + styles.cellClamp, theme, timeRange, ] 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/styles.ts b/packages/grafana-ui/src/components/Table/TableNG/styles.ts index 1094108a587..89c16e266a9 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/styles.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/styles.ts @@ -80,6 +80,10 @@ export const getGridStyles = (theme: GrafanaTheme2, enablePagination?: boolean, marginBlock: TABLE.CELL_PADDING, }), cellNested: css({ '&[aria-selected=true]': { outline: 'none' } }), + cellClamp: css({ + overflowY: 'hidden', + justifyContent: 'flex-start !important', + }), noDataNested: css({ height: TABLE.NESTED_NO_DATA_HEIGHT, display: 'flex', @@ -183,6 +187,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, diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 123a8f39e7d..10e419121b2 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -258,6 +258,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..5946b3865b6 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); + 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..e22104dbd6f 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -84,6 +84,25 @@ export function shouldTextWrap(field: Field): boolean { return Boolean(field.config.custom?.wrapText); } +export function getMaxHeight(field: Field): number | undefined { + return field.config?.custom?.maxHeight; +} + +/** + * @internal wrap a cell height measurer to clamp its output to the maxHeight defined in the field, if any. + */ +function clampByMaxHeight(measurer: MeasureCellHeight): MeasureCellHeight { + return (value, width, field, rowIdx, lineHeight) => { + const rawHeight = measurer(value, width, field, rowIdx, lineHeight); + const maxHeight = getMaxHeight(field); + if (typeof maxHeight !== 'number') { + return rawHeight; + } + + 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. @@ -279,8 +298,8 @@ export function buildCellHeightMeasurers( if (!result[measurerFactoryKey]) { const [measure, estimate] = measurerFactory[measurerFactoryKey](); result[measurerFactoryKey] = { - measure, - estimate, + measure: clampByMaxHeight(measure), + estimate: estimate != null ? clampByMaxHeight(estimate) : undefined, fieldIdxs: [], }; } diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 2dafbfba899..83399bbf018 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -113,6 +113,16 @@ export const plugin = new PanelPlugin(TablePanel) name: t('table.name-wrap-text', 'Wrap text'), category, }) + .addNumberInput({ + path: 'maxHeight', + name: t('table.text-wrap-options.label-max-height', 'Max cell height'), + category, + settings: { + placeholder: t('table.text-wrap-options.placeholder-max-height', 'none'), + min: 0, + }, + showIf: (cfg) => cfg.wrapText, + }) .addBooleanSwitch({ path: 'wrapHeaderText', name: t('table.name-wrap-header-text', 'Wrap header text'), diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 86c5e18ab7d..14c4abc0315 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12808,6 +12808,10 @@ "name-wrap-text": "Wrap text", "placeholder-column-width": "auto", "placeholder-fields": "All Numeric Fields", + "text-wrap-options": { + "label-max-height": "Max cell height", + "placeholder-max-height": "none" + }, "tooltip-placement-options": { "label-auto": "Auto", "label-bottom": "Bottom",