Table: Max height for wrapped content
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user