mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Table Panel: Enable text wrapping (#86895)
* Calculate row height
* Move things around
* Update getItemSize to use text bounding box
* Update types
* Cleanups and reminders
* Calculate line height
* Update line height calculation
* Remove debugging
* Add cell option editing
* Prettier
* Use field configured for text wrapping
* Add TODO
* Make sure column configuration is correct
* Update height heuristic and hover behavior
* Disable overflow on hover with text wrapping
* Update heuristic
* Clean things up
* Color background cell options
* Fix tests
* Prettier
* React deps
* Remove old hook dep
* Fix type errors
* Update label and description for editor
* Fix non-wrapped case
* Make sure color background works
* Prettier
* Address review comments
* fix prettier
* Add heuristic for field sizing
* Fix up logic
* Prettier
* Fix test
* Oh prettier 🙈
* Don't wrap text on non-string fields
* Add wrapping to color text cell
* Prettier
* Fix option not showing for auto cell type
* Move longest field guessing into function
* Clean things up
* Add tests
* Make sure text won't flake
* Prettier
* Remove spurious import
* Ignore any in this case
* Add alpha label
* Prettier
* Fix typecheck
* Fix crash when sampling when there are undefined records
* Update heuristic to take into account long strings
* Prettier
* Update scale factors
* Update field index selection
* Prettier
---------
Co-authored-by: jev forsberg <jev.forsberg@grafana.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
parent
694499ae6d
commit
8aa1bbe27c
@ -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=
|
||||
|
@ -749,6 +749,7 @@ export const defaultTableFooterOptions: Partial<TableFooterOptions> = {
|
||||
*/
|
||||
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',
|
||||
|
@ -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.
|
||||
|
@ -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",
|
||||
|
@ -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 (
|
||||
<div
|
||||
{...cellProps}
|
||||
@ -112,6 +121,7 @@ function getCellStyle(
|
||||
disableOverflowOnHover = false,
|
||||
isStringValue = false,
|
||||
shouldWrapText = false,
|
||||
textWrapped = false,
|
||||
rowStyled = false
|
||||
) {
|
||||
// Setup color variables
|
||||
@ -134,6 +144,7 @@ function getCellStyle(
|
||||
!disableOverflowOnHover,
|
||||
isStringValue,
|
||||
shouldWrapText,
|
||||
textWrapped,
|
||||
rowStyled
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { CSSProperties, UIEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Cell, Row, TableState } from 'react-table';
|
||||
import { Cell, Row, TableState, HeaderGroup } from 'react-table';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
import { Subscription, debounceTime } from 'rxjs';
|
||||
|
||||
@ -23,7 +23,12 @@ import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
|
||||
import { TableCell } from './TableCell';
|
||||
import { TableStyles } from './styles';
|
||||
import { CellColors, TableFieldOptions, TableFilterActionCallback } from './types';
|
||||
import { calculateAroundPointThreshold, getCellColors, isPointTimeValAroundTableTimeVal } from './utils';
|
||||
import {
|
||||
calculateAroundPointThreshold,
|
||||
getCellColors,
|
||||
isPointTimeValAroundTableTimeVal,
|
||||
guessTextBoundingBox,
|
||||
} from './utils';
|
||||
|
||||
interface RowsListProps {
|
||||
data: DataFrame;
|
||||
@ -45,6 +50,8 @@ interface RowsListProps {
|
||||
timeRange?: TimeRange;
|
||||
footerPaginationEnabled: boolean;
|
||||
initialRowIndex?: number;
|
||||
headerGroups: HeaderGroup[];
|
||||
longestField?: Field;
|
||||
}
|
||||
|
||||
export const RowsList = (props: RowsListProps) => {
|
||||
@ -68,6 +75,8 @@ export const RowsList = (props: RowsListProps) => {
|
||||
listRef,
|
||||
enableSharedCrosshair = false,
|
||||
initialRowIndex = undefined,
|
||||
headerGroups,
|
||||
longestField,
|
||||
} = props;
|
||||
|
||||
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(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 (
|
||||
<div
|
||||
{...row.getRowProps({ style, ...additionalProps })}
|
||||
@ -272,6 +326,8 @@ export const RowsList = (props: RowsListProps) => {
|
||||
timeRange={timeRange}
|
||||
frame={data}
|
||||
rowStyled={rowBg !== undefined}
|
||||
textWrapped={textWrapField !== undefined}
|
||||
height={Number(style.height)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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 (
|
||||
<div
|
||||
{...getTableProps()}
|
||||
@ -304,6 +314,7 @@ export const Table = memo((props: Props) => {
|
||||
{itemCount > 0 ? (
|
||||
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
|
||||
<RowsList
|
||||
headerGroups={headerGroups}
|
||||
data={data}
|
||||
rows={rows}
|
||||
width={width}
|
||||
@ -323,6 +334,7 @@ export const Table = memo((props: Props) => {
|
||||
footerPaginationEnabled={Boolean(enablePagination)}
|
||||
enableSharedCrosshair={enableSharedCrosshair}
|
||||
initialRowIndex={initialRowIndex}
|
||||
longestField={longestField}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) => {
|
||||
<Field>
|
||||
<Select options={cellDisplayModeOptions} value={currentMode} onChange={onCellTypeChange} />
|
||||
</Field>
|
||||
{(cellType === TableCellDisplayMode.Auto || cellType === TableCellDisplayMode.ColorText) && (
|
||||
<AutoCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
|
||||
)}
|
||||
{cellType === TableCellDisplayMode.Gauge && (
|
||||
<BarGaugeCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
|
||||
)}
|
||||
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TableAutoCellOptions, TableColorTextCellOptions } from '@grafana/schema';
|
||||
import { Field, Switch, Badge, Label } from '@grafana/ui';
|
||||
|
||||
import { TableCellEditorProps } from '../TableCellOptionEditor';
|
||||
|
||||
export const AutoCellOptionsEditor = ({
|
||||
cellOptions,
|
||||
onChange,
|
||||
}: TableCellEditorProps<TableAutoCellOptions | TableColorTextCellOptions>) => {
|
||||
// Handle row coloring changes
|
||||
const onWrapTextChange = () => {
|
||||
cellOptions.wrapText = !cellOptions.wrapText;
|
||||
onChange(cellOptions);
|
||||
};
|
||||
|
||||
const label = (
|
||||
<Label description="If selected text will be wrapped to the width of text in the configured column">
|
||||
{'Wrap text '}
|
||||
<Badge text="Alpha" color="blue" style={{ fontSize: '11px', marginLeft: '5px', lineHeight: '1.2' }} />
|
||||
</Label>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label={label}>
|
||||
<Switch value={cellOptions.wrapText} onChange={onWrapTextChange} />
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
};
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
|
||||
import { Field, RadioButtonGroup, Switch } from '@grafana/ui';
|
||||
import { Field, RadioButtonGroup, Switch, Label, Badge } from '@grafana/ui';
|
||||
|
||||
import { TableCellEditorProps } from '../TableCellOptionEditor';
|
||||
|
||||
@ -27,6 +27,19 @@ export const ColorBackgroundCellOptionsEditor = ({
|
||||
onChange(cellOptions);
|
||||
};
|
||||
|
||||
// Handle row coloring changes
|
||||
const onWrapTextChange = () => {
|
||||
cellOptions.wrapText = !cellOptions.wrapText;
|
||||
onChange(cellOptions);
|
||||
};
|
||||
|
||||
const label = (
|
||||
<Label description="If selected text will be wrapped to the width of text in the configured column">
|
||||
{'Wrap text '}
|
||||
<Badge text="Alpha" color="blue" style={{ fontSize: '11px', marginLeft: '5px', lineHeight: '1.2' }} />
|
||||
</Label>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Background display mode">
|
||||
@ -42,6 +55,9 @@ export const ColorBackgroundCellOptionsEditor = ({
|
||||
>
|
||||
<Switch value={cellOptions.applyToRow} onChange={onColorRowChange} />
|
||||
</Field>
|
||||
<Field label={label}>
|
||||
<Switch value={cellOptions.wrapText} onChange={onWrapTextChange} />
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -2418,6 +2418,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@faker-js/faker@npm:^8.4.1":
|
||||
version: 8.4.1
|
||||
resolution: "@faker-js/faker@npm:8.4.1"
|
||||
checksum: 10/5983c2ea64f26055ad6648de748878e11ebe2fb751e3c7435ae141cdffabc2dccfe4c4f49da69a3d2add71e21b415c683ac5fba196fab0d5ed6779fbec436c80
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fal-works/esbuild-plugin-global-externals@npm:^2.1.2":
|
||||
version: 2.1.2
|
||||
resolution: "@fal-works/esbuild-plugin-global-externals@npm:2.1.2"
|
||||
@ -3605,6 +3612,7 @@ __metadata:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@emotion/css": "npm:11.11.2"
|
||||
"@emotion/react": "npm:11.11.4"
|
||||
"@faker-js/faker": "npm:^8.4.1"
|
||||
"@floating-ui/react": "npm:0.26.16"
|
||||
"@grafana/data": "npm:11.0.0"
|
||||
"@grafana/e2e-selectors": "npm:11.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user