diff --git a/go.work.sum b/go.work.sum index 482604b8802..4534880c0ac 100644 --- a/go.work.sum +++ b/go.work.sum @@ -565,8 +565,6 @@ github.com/grafana/grafana-plugin-sdk-go v0.227.1-0.20240430073540-ce4d126ae8b8 github.com/grafana/grafana-plugin-sdk-go v0.227.1-0.20240430073540-ce4d126ae8b8/go.mod h1:u4K9vVN6eU86loO68977eTXGypC4brUCnk4sfDzutZU= github.com/grafana/grafana-plugin-sdk-go v0.228.0/go.mod h1:u4K9vVN6eU86loO68977eTXGypC4brUCnk4sfDzutZU= github.com/grafana/grafana-plugin-sdk-go v0.229.0/go.mod h1:6V6ikT4ryva8MrAp7Bdz5fTJx3/ztzKvpMJFfpzr4CI= -github.com/grafana/grafana-plugin-sdk-go v0.230.0 h1:Y4IL+eT1jXqTCctlNzdCvxAozpBZ8xEsRGWjGAVRXxo= -github.com/grafana/grafana-plugin-sdk-go v0.230.0/go.mod h1:6V6ikT4ryva8MrAp7Bdz5fTJx3/ztzKvpMJFfpzr4CI= github.com/grafana/grafana-plugin-sdk-go v0.231.1-0.20240523124942-62dae9836284/go.mod h1:bNgmNmub1I7Mc8dzIncgNqHC5jTgSZPPHlZ3aG8HKJQ= github.com/grafana/grafana/pkg/promlib v0.0.3/go.mod h1:3El4NlsfALz8QQCbEGHGFvJUG+538QLMuALRhZ3pcoo= github.com/grafana/grafana/pkg/promlib v0.0.6/go.mod h1:shFkrG1fQ/PPNRGhxAPNMLp0SAeG/jhqaLoG6n2191M= @@ -983,6 +981,7 @@ golang.org/x/image v0.0.0-20220302094943-723b81ca9867 h1:TcHcE0vrmgzNH1v3ppjcMGb golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808 h1:+Kc94D8UVEVxJnLXp/+FMfqQARZtWHfVrcRtcG8aT3g= +golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index ba472408431..960abb68275 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -749,6 +749,7 @@ export const defaultTableFooterOptions: Partial = { */ export interface TableAutoCellOptions { type: TableCellDisplayMode.Auto; + wrapText?: boolean; } /** @@ -756,6 +757,7 @@ export interface TableAutoCellOptions { */ export interface TableColorTextCellOptions { type: TableCellDisplayMode.ColorText; + wrapText?: boolean; } /** @@ -803,12 +805,14 @@ export interface TableColoredBackgroundCellOptions { applyToRow?: boolean; mode?: TableCellBackgroundDisplayMode; type: TableCellDisplayMode.ColorBackground; + wrapText?: boolean; } /** * Height of a table cell */ export enum TableCellHeight { + Auto = 'auto', Lg = 'lg', Md = 'md', Sm = 'sm', diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 5c6dce2a5de..417d9768ea6 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -31,11 +31,13 @@ TableFooterOptions: { // Auto mode table cell options TableAutoCellOptions: { type: TableCellDisplayMode & "auto" + wrapText?: bool } @cuetsy(kind="interface") // Colored text cell options TableColorTextCellOptions: { type: TableCellDisplayMode & "color-text" + wrapText?: bool } @cuetsy(kind="interface") // Json view cell options @@ -72,10 +74,11 @@ TableColoredBackgroundCellOptions: { type: TableCellDisplayMode & "color-background" mode?: TableCellBackgroundDisplayMode applyToRow?: bool + wrapText?: bool } @cuetsy(kind="interface") // Height of a table cell -TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum") +TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum") // Table cell options. Each cell has a display mode // and other potential options for that display. diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index db5386dab63..f497b997252 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -110,6 +110,7 @@ }, "devDependencies": { "@babel/core": "7.24.7", + "@faker-js/faker": "^8.4.1", "@grafana/tsconfig": "^1.3.0-rc1", "@rollup/plugin-node-resolve": "15.2.3", "@storybook/addon-a11y": "^8.1.6", diff --git a/packages/grafana-ui/src/components/Table/DefaultCell.tsx b/packages/grafana-ui/src/components/Table/DefaultCell.tsx index 7ae6fd6fac1..466e708de73 100644 --- a/packages/grafana-ui/src/components/Table/DefaultCell.tsx +++ b/packages/grafana-ui/src/components/Table/DefaultCell.tsx @@ -15,7 +15,7 @@ import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './typ import { getCellColors, getCellOptions } from './utils'; export const DefaultCell = (props: TableCellProps) => { - const { field, cell, tableStyles, row, cellProps, frame, rowStyled } = props; + const { field, cell, tableStyles, row, cellProps, frame, rowStyled, textWrapped, height } = props; const inspectEnabled = Boolean(field.config.custom?.inspect); const displayValue = field.display!(cell.value); @@ -59,6 +59,7 @@ export const DefaultCell = (props: TableCellProps) => { inspectEnabled, isStringValue, textShouldWrap, + textWrapped, rowStyled ); @@ -72,6 +73,14 @@ export const DefaultCell = (props: TableCellProps) => { } } + if (height) { + cellProps.style = { ...cellProps.style, height }; + } + + if (textWrapped) { + cellProps.style = { ...cellProps.style, textWrap: 'wrap' }; + } + return (
{ @@ -68,6 +75,8 @@ export const RowsList = (props: RowsListProps) => { listRef, enableSharedCrosshair = false, initialRowIndex = undefined, + headerGroups, + longestField, } = props; const [rowHighlightIndex, setRowHighlightIndex] = useState(initialRowIndex); @@ -75,6 +84,23 @@ export const RowsList = (props: RowsListProps) => { const theme = useTheme2(); const panelContext = usePanelContext(); + // Create off-screen canvas for measuring rows for virtualized rendering + // This line is like this because Jest doesn't have OffscreenCanvas mocked + // nor is it a part of the jest-canvas-mock package + let osContext = null; + if (window.OffscreenCanvas !== undefined) { + // The canvas size is defined arbitrarily + // As we never actually visualize rendered content + // from the offscreen canvas, only perform text measurements + osContext = new OffscreenCanvas(256, 1024).getContext('2d'); + } + + // Set font property using theme info + // This will make text measurement accurate + if (osContext !== undefined && osContext !== null) { + osContext.font = `${theme.typography.fontSize}px ${theme.typography.body.fontFamily}`; + } + const threshold = useMemo(() => { const timeField = data.fields.find((f) => f.type === FieldType.time); @@ -202,13 +228,14 @@ export const RowsList = (props: RowsListProps) => { ); let rowBg: Function | undefined = undefined; + let textWrapField: Field | undefined = undefined; for (const field of data.fields) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const fieldOptions = field.config.custom as TableFieldOptions; + const cellOptionsExist = fieldOptions !== undefined && fieldOptions.cellOptions !== undefined; if ( - fieldOptions !== undefined && - fieldOptions.cellOptions !== undefined && + cellOptionsExist && fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground && fieldOptions.cellOptions.applyToRow ) { @@ -218,6 +245,18 @@ export const RowsList = (props: RowsListProps) => { return colors; }; } + + if ( + cellOptionsExist && + (fieldOptions.cellOptions.type === TableCellDisplayMode.Auto || + fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground || + fieldOptions.cellOptions.type === TableCellDisplayMode.ColorText) && + fieldOptions.cellOptions.wrapText + ) { + textWrapField = field; + } else if (longestField !== undefined) { + textWrapField = longestField; + } } const RenderRow = useCallback( @@ -236,12 +275,27 @@ export const RowsList = (props: RowsListProps) => { }; } + // Color rows if enabled if (rowBg) { const { bgColor, textColor } = rowBg(row.index); style.background = bgColor; style.color = textColor; } + // If there's a text wrapping field we set the height of it here + if (textWrapField) { + const seriesIndex = data.fields.findIndex((field) => field.name === textWrapField.name); + const pxLineHeight = theme.typography.body.lineHeight * theme.typography.fontSize; + const bbox = guessTextBoundingBox( + textWrapField.values[index], + headerGroups[0].headers[seriesIndex], + osContext, + pxLineHeight, + tableStyles.rowHeight + ); + style.height = bbox.height; + } + return (
{ timeRange={timeRange} frame={data} rowStyled={rowBg !== undefined} + textWrapped={textWrapField !== undefined} + height={Number(style.height)} /> ))}
@@ -289,20 +345,38 @@ export const RowsList = (props: RowsListProps) => { rows, tableState.expanded, tableStyles, + textWrapField, theme.components.table.rowHoverBackground, + theme.typography.fontSize, + theme.typography.body.lineHeight, timeRange, width, rowBg, + headerGroups, + osContext, ] ); const getItemSize = (index: number): number => { const indexForPagination = rowIndexForPagination(index); const row = rows[indexForPagination]; + if (tableState.expanded[row.id] && nestedDataField) { return getExpandedRowHeight(nestedDataField, row.index, tableStyles); } + if (textWrapField) { + const seriesIndex = data.fields.findIndex((field) => field.name === textWrapField.name); + const pxLineHeight = theme.typography.fontSize * theme.typography.body.lineHeight; + return guessTextBoundingBox( + textWrapField.values[index], + headerGroups[0].headers[seriesIndex], + osContext, + pxLineHeight, + tableStyles.rowHeight + ).height; + } + return tableStyles.rowHeight; }; diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index b418401bc94..e9c8c38ae0f 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -25,7 +25,14 @@ import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks import { getInitialState, useTableStateReducer } from './reducer'; import { useTableStyles } from './styles'; import { FooterItem, GrafanaTableState, Props } from './types'; -import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils'; +import { + getColumns, + sortCaseInsensitive, + sortNumber, + getFooterItems, + createFooterCalculationValues, + guessLongestField, +} from './utils'; const COLUMN_MIN_WIDTH = 150; const FOOTER_ROW_HEIGHT = 36; @@ -287,6 +294,9 @@ export const Table = memo((props: Props) => { ); } + // Try to determine the longet field + const longestField = guessLongestField(fieldConfig, data); + return (
{ {itemCount > 0 ? (
{ footerPaginationEnabled={Boolean(enablePagination)} enableSharedCrosshair={enableSharedCrosshair} initialRowIndex={initialRowIndex} + longestField={longestField} />
) : ( diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index 4419560aa93..00986044a02 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -16,9 +16,21 @@ export interface Props { userProps?: object; frame: DataFrame; rowStyled?: boolean; + textWrapped?: boolean; + height?: number; } -export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, userProps, frame, rowStyled }: Props) => { +export const TableCell = ({ + cell, + tableStyles, + onCellFilterAdded, + timeRange, + userProps, + frame, + rowStyled, + textWrapped, + height, +}: Props) => { const cellProps = cell.getCellProps(); const field = (cell.column as unknown as GrafanaTableColumn).field; @@ -45,6 +57,8 @@ export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, use userProps, frame, rowStyled, + textWrapped, + height, })} ); diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index a7647c978aa..60b673998f0 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -18,6 +18,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell overflowOnHover?: boolean, asCellText?: boolean, textShouldWrap?: boolean, + textWrapped?: boolean, rowStyled?: boolean ) => { return css({ @@ -26,6 +27,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell width: '100%', // Cell height need to account for row border height: `${rowHeight - 1}px`, + wordBreak: textWrapped ? 'break-all' : 'inherit', display: 'flex', @@ -50,9 +52,9 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell }, '&:hover': { - overflow: overflowOnHover ? 'visible' : undefined, + overflow: overflowOnHover && !textWrapped ? 'visible' : undefined, width: textShouldWrap || !overflowOnHover ? 'auto' : 'auto !important', - height: textShouldWrap || overflowOnHover ? 'auto !important' : `${rowHeight - 1}px`, + height: (textShouldWrap || overflowOnHover) && !textWrapped ? 'auto !important' : `${rowHeight - 1}px`, minHeight: `${rowHeight - 1}px`, wordBreak: textShouldWrap ? 'break-word' : undefined, whiteSpace: textShouldWrap && overflowOnHover ? 'normal' : 'nowrap', diff --git a/packages/grafana-ui/src/components/Table/utils.test.ts b/packages/grafana-ui/src/components/Table/utils.test.ts index f24d63d2650..6f1d7f937f5 100644 --- a/packages/grafana-ui/src/components/Table/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/utils.test.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import { Row } from 'react-table'; import { Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data'; @@ -12,6 +13,7 @@ import { sortNumber, sortOptions, valuesToOptions, + guessLongestField, } from './utils'; function getData() { @@ -43,6 +45,45 @@ function getData() { return data; } +function getWrappableData(numRecords: number) { + const data = new MutableDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [] }, + { + name: 'Lorem 5', + type: FieldType.string, + values: [], + config: { + custom: { + align: 'center', + }, + }, + }, + { + name: 'Lorem 10', + type: FieldType.string, + values: [], + config: { + custom: { + align: 'center', + }, + }, + }, + ], + }); + + // Set values for the dataframe + // We're not concerned about time in + // this case so we simply leave it as zero + for (let i = 0; i < numRecords; i++) { + data.fields[0].values[i] = 0; + data.fields[1].values[i] = faker.lorem.paragraphs(9); + data.fields[2].values[i] = faker.lorem.paragraphs(11); + } + + return data; +} + describe('Table utils', () => { describe('getColumns', () => { it('Should build columns from DataFrame', () => { @@ -502,4 +543,54 @@ describe('Table utils', () => { expect(diff).toBeLessThanOrEqual(20); }); }); + + describe('guessLongestField', () => { + it('should guess the longest field correct if there are few records', () => { + const data = getWrappableData(10); + const config = { + defaults: { + custom: { + cellOptions: { + wrapText: true, + }, + }, + }, + }; + + const longestField = guessLongestField(config, data); + expect(longestField?.name).toBe('Lorem 10'); + }); + + it('should guess the longest field correctly if there are many records', () => { + const data = getWrappableData(1000); + const config = { + defaults: { + custom: { + cellOptions: { + wrapText: true, + }, + }, + }, + }; + + const longestField = guessLongestField(config, data); + expect(longestField?.name).toBe('Lorem 10'); + }); + + it('should return undefined if there is no data', () => { + const data = getData(); + const config = { + defaults: { + custom: { + cellOptions: { + wrapText: true, + }, + }, + }, + }; + + const longestField = guessLongestField(config, data); + expect(longestField).toBe(undefined); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index d603c86a530..940734cbd2f 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -1,7 +1,7 @@ import { Property } from 'csstype'; -import { clone } from 'lodash'; +import { clone, sampleSize } from 'lodash'; import memoize from 'micro-memoize'; -import { Row } from 'react-table'; +import { Row, HeaderGroup } from 'react-table'; import tinycolor from 'tinycolor2'; import { @@ -633,3 +633,130 @@ export function getCellColors( return { textColor, bgColor, bgHoverColor }; } + +/** + * Calculate an estimated bounding box for a block + * of text using an offscreen canvas. + */ +export function guessTextBoundingBox( + text: string, + headerGroup: HeaderGroup, + osContext: OffscreenCanvasRenderingContext2D | null, + lineHeight: number, + defaultRowHeight: number +) { + const width = Number(headerGroup.width ?? 300); + const LINE_SCALE_FACTOR = 1.17; + const LOW_LINE_PAD = 42; + + if (osContext !== null && typeof text === 'string') { + const words = text.split(/\s/); + const lines = []; + let currentLine = ''; + let wordCount = 0; + let extraLines = 0; + + // Let's just wrap the lines and see how well the measurement works + for (let i = 0; i < words.length; i++) { + const currentWord = words[i]; + let lineWidth = osContext.measureText(currentLine + ' ' + currentWord).width; + + if (lineWidth < width) { + currentLine += ' ' + currentWord; + wordCount++; + } else { + lines.push({ + width: lineWidth, + line: currentLine, + }); + + currentLine = currentWord; + wordCount = 0; + } + } + + // We can have extra long strings, for these + // we estimate if it overshoots the line by + // at least one other line + for (let i = 0; i < lines.length; i++) { + if (lines[i].width > width) { + let extra = Math.floor(lines[i].width / width) - 1; + extraLines += extra; + } + } + + // Estimated height would be lines multiplied + // by the line height + let lineNumber = lines.length + extraLines; + let height = 38; + if (lineNumber > 5) { + height = lineNumber * lineHeight * LINE_SCALE_FACTOR; + } else { + height = lineNumber * lineHeight + LOW_LINE_PAD; + } + + return { width, height }; + } + + return { width, height: defaultRowHeight }; +} + +/** + * A function to guess at which field has the longest text. + * To do this we either select a single record if there aren't many records + * or we select records at random and sample their size. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function guessLongestField(fieldConfig: any, data: DataFrame) { + let longestField = undefined; + const SAMPLE_SIZE = 3; + + // If the default field option is set to allow text wrapping + // we determine the field to wrap text with here and then + // pass it to the RowsList + if ( + fieldConfig !== undefined && + fieldConfig.defaults.custom !== undefined && + fieldConfig.defaults.custom.cellOptions.wrapText + ) { + const stringFields = data.fields.filter((field: Field) => field.type === FieldType.string); + + if (stringFields.length >= 1 && stringFields[0].values.length > 0) { + const numValues = stringFields[0].values.length; + let longestLength = 0; + + // If we have less than 30 values we assume + // that the first record is representative + // of the overall data + if (numValues <= 30) { + for (const field of stringFields) { + const fieldLength = field.values[0].length; + if (fieldLength > longestLength) { + longestLength = fieldLength; + longestField = field; + } + } + } + // Otherwise we randomly sample SAMPLE_SIZE values and take + // the mean length + else { + for (const field of stringFields) { + // This could result in duplicate values but + // that should be fairly unlikely. This could potentially + // be improved using a Set datastructure but + // going to leave that one as an exercise for + // the reader to contemplate and possibly code + const vals = sampleSize(field.values, SAMPLE_SIZE); + const meanLength = (vals[0]?.length + vals[1]?.length + vals[2]?.length) / 3; + + if (meanLength > longestLength) { + longestLength = meanLength; + longestField = field; + } + } + } + } + } + + return longestField; +} diff --git a/public/app/plugins/panel/table/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/TableCellOptionEditor.tsx index 1374b5ee64c..c4b90e9812a 100644 --- a/public/app/plugins/panel/table/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/TableCellOptionEditor.tsx @@ -6,6 +6,7 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { TableCellOptions } from '@grafana/schema'; import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui'; +import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor'; import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor'; import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor'; import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor'; @@ -60,6 +61,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {