Table: Max height for wrapped content

This commit is contained in:
Paul Marbach
2025-08-27 16:40:32 -04:00
parent 76b1e5e389
commit c6f7797ab5
13 changed files with 138 additions and 20 deletions
@@ -295,6 +295,10 @@
"wrapText": true
}
},
{
"id": "custom.maxHeight",
"value": null
},
{
"id": "custom.width",
"value": 255
@@ -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.
@@ -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
@@ -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
@@ -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 }) =>
@@ -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) {
<Cell
key={key}
{...props}
className={clsx(props.className, defaultCellStyles, cellSpecificStyles, linkStyles)}
className={clsx(props.className, { [cellParentStyles]: maxHeight == null })}
style={style}
/>
);
@@ -465,6 +468,12 @@ export function TableNG(props: TableNGProps) {
result.cellRootRenderers[displayName] = renderCellRoot;
const clampByMaxHeight = (maxHeight: number, children: ReactNode, cellStyles: string) => (
<div className={clsx(styles.cellClamp, cellStyles)} style={{ maxHeight }}>
{children}
</div>
);
const renderBasicCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): 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 = (
<>
<CellType
cellOptions={cellOptions}
@@ -508,6 +517,12 @@ export function TableNG(props: TableNGProps) {
)}
</>
);
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, <OrigCellRenderer {...props} />, 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 (
<TableCellTooltip {...tooltipProps} height={tooltipHeight} rowIdx={props.rowIdx} style={tooltipStyle}>
<TableCellTooltip
{...tooltipProps}
height={tooltipHeight}
rowIdx={props.row.__index}
style={tooltipStyle}
>
{renderBasicCellContent(props)}
</TableCellTooltip>
);
@@ -646,6 +671,7 @@ export function TableNG(props: TableNGProps) {
rows,
setFilter,
showTypeIcons,
styles.cellClamp,
theme,
timeRange,
]
@@ -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}
@@ -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,
@@ -258,6 +258,7 @@ export interface TableCellStyleOptions {
textWrap: boolean;
textAlign: TextAlign;
shouldOverflow: boolean;
maxHeight?: number;
}
export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOptions) => string;
@@ -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', () => {
@@ -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: [],
};
}
+10
View File
@@ -113,6 +113,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(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'),
+4
View File
@@ -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",