diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 553dd3b8fa7..8738aab4d7c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -131,7 +131,7 @@ "tslib": "2.8.1", "uplot": "1.6.32", "uuid": "11.1.0", - "uwrap": "0.1.1" + "uwrap": "0.1.2" }, "devDependencies": { "@babel/core": "7.28.0", diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index 9953ef0948e..a9fd4420ff3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -8,11 +8,8 @@ import { TableCellDisplayMode } from '../../types'; import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; import { ImageCellProps } from '../types'; -const DATALINKS_HEIGHT_OFFSET = 10; - export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => { - const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET; - const styles = useStyles2(getStyles, calculatedHeight, justifyContent); + const styles = useStyles2(getStyles, height, justifyContent); const { text } = field.display!(value); const { alt, title } = @@ -27,7 +24,7 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r ); }; -const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ +const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ image: css({ height, width: 'auto', diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index c84fb2d4194..40cd49278d3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -26,7 +26,7 @@ import { ReducerID, } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { FieldColorModeId, TableCellHeight } from '@grafana/schema'; +import { FieldColorModeId } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; import { ContextMenu } from '../../ContextMenu/ContextMenu'; @@ -52,29 +52,30 @@ import { useRowHeight, useScrollbarWidth, useSortedRows, - useTypographyCtx, } from './hooks'; import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types'; import { + applySort, + computeColWidths, + createTypographyContext, + displayJsonValue, + extractPixelValue, frameToRecords, + getAlignment, + getApplyToRowBgFn, + getCellColors, + getCellLinks, + getCellOptions, getDefaultRowHeight, getDisplayName, getIsNestedTable, - getVisibleFields, - shouldTextOverflow, - getApplyToRowBgFn, - computeColWidths, - applySort, - getCellColors, - getCellOptions, - shouldTextWrap, - isCellInspectEnabled, - getCellLinks, - withDataLinksActionsTooltip, - displayJsonValue, - getAlignment, getJustifyContent, + getVisibleFields, + isCellInspectEnabled, + shouldTextOverflow, + shouldTextWrap, TextAlign, + withDataLinksActionsTooltip, } from './utils'; type CellRootRenderer = (key: React.Key, props: CellRendererProps) => React.ReactNode; @@ -160,7 +161,6 @@ export function TableNG(props: TableNGProps) { } = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy }); const defaultRowHeight = getDefaultRowHeight(theme, cellHeight); - const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm); const [isInspecting, setIsInspecting] = useState(false); const [expandedRows, setExpandedRows] = useState(() => new Set()); @@ -172,13 +172,20 @@ export function TableNG(props: TableNGProps) { () => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth, [width, hasNestedFrames, scrollbarWidth] ); - const typographyCtx = useTypographyCtx(); + const typographyCtx = useMemo( + () => + createTypographyContext( + theme.typography.fontSize, + theme.typography.fontFamily, + extractPixelValue(theme.typography.body.letterSpacing!) * theme.typography.fontSize + ), + [theme] + ); const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]); const headerHeight = useHeaderHeight({ columnWidths: widths, fields: visibleFields, enabled: hasHeader, - defaultHeight: defaultHeaderHeight, sortColumns, showTypeIcons: showTypeIcons ?? false, typographyCtx, @@ -285,7 +292,6 @@ export function TableNG(props: TableNGProps) { }; let lastRowIdx = -1; - let _rowHeight = 0; // shared when whole row will be styled by a single cell's color let rowCellStyle: Partial = { color: undefined, @@ -381,7 +387,6 @@ export function TableNG(props: TableNGProps) { // meh, this should be cached by the renderRow() call? if (rowIdx !== lastRowIdx) { - _rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; lastRowIdx = rowIdx; rowCellStyle.color = undefined; @@ -420,6 +425,9 @@ export function TableNG(props: TableNGProps) { const renderCellContent = (props: RenderCellProps): JSX.Element => { const rowIdx = props.row.__index; const value = props.row[props.column.key]; + // TODO: it would be nice to get rid of passing height down as a prop. but this value + // is cached so the cost of calling for every cell is low. + const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; const frame = data; return ( @@ -428,7 +436,7 @@ export function TableNG(props: TableNGProps) { cellOptions, frame, field, - height: _rowHeight, + height, justifyContent, rowIdx, theme, @@ -580,7 +588,7 @@ export function TableNG(props: TableNGProps) { {...commonDataGridProps} className={clsx(styles.grid, styles.gridNested)} headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })} - headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0} + headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0} columns={nestedColumns} rows={expandedRecords} renderers={{ renderRow, renderCell: renderCellRoot }} @@ -599,7 +607,6 @@ export function TableNG(props: TableNGProps) { crossFilterOrder, crossFilterRows, data, - defaultHeaderHeight, defaultRowHeight, enableSharedCrosshair, expandedRows, diff --git a/packages/grafana-ui/src/components/Table/TableNG/constants.ts b/packages/grafana-ui/src/components/Table/TableNG/constants.ts index 31a537136d2..7638dd98804 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/constants.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/constants.ts @@ -13,7 +13,9 @@ export const TABLE = { PAGINATION_LIMIT: 750, SCROLL_BAR_WIDTH: 8, SCROLL_BAR_MARGIN: 2, + FONT_SIZE: 14, LINE_HEIGHT: 22, + HEADER_HEIGHT: 28, NESTED_NO_DATA_HEIGHT: 60, - BORDER_RIGHT: 0.666667, + BORDER_RIGHT: 1, }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts index 4df9128205a..f7a1a1b858a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -1,23 +1,19 @@ import { act, renderHook } from '@testing-library/react'; -import { varPreLine } from 'uwrap'; import { cacheFieldDisplayNames, createDataFrame, Field, FieldType } from '@grafana/data'; +import { TableCellDisplayMode } from '@grafana/schema'; +import { TABLE } from './constants'; import { useFilteredRows, usePaginatedRows, useSortedRows, useFooterCalcs, useHeaderHeight, - useTypographyCtx, + useRowHeight, } from './hooks'; - -jest.mock('uwrap', () => ({ - // ...jest.requireActual('uwrap'), - varPreLine: jest.fn(() => ({ - count: jest.fn(() => 1), - })), -})); +import { TableRow } from './types'; +import { createTypographyContext } from './utils'; describe('TableNG hooks', () => { function setupData() { @@ -28,21 +24,21 @@ describe('TableNG hooks', () => { type: FieldType.string, display: (v) => ({ text: v as string, numeric: NaN }), config: {}, - values: [], + values: ['Alice', 'Bob', 'Charlie'], }, { name: 'age', type: FieldType.number, display: (v) => ({ text: (v as number).toString(), numeric: v as number }), config: {}, - values: [], + values: [30, 25, 35], }, { name: 'active', type: FieldType.boolean, display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }), config: {}, - values: [], + values: [true, false, true], }, ]; @@ -149,7 +145,7 @@ describe('TableNG hooks', () => { height: 300, width: 800, enabled: false, - headerHeight: 28, + headerHeight: TABLE.HEADER_HEIGHT, footerHeight: 0, }) ); @@ -201,7 +197,7 @@ describe('TableNG hooks', () => { height: 140, width: 800, rowHeight: 10, - headerHeight: 28, + headerHeight: TABLE.HEADER_HEIGHT, footerHeight: 45, }) ); @@ -429,16 +425,16 @@ describe('TableNG hooks', () => { }); describe('useHeaderHeight', () => { + const typographyCtx = createTypographyContext(14, 'sans-serif'); + it('should return 0 when no header is present', () => { const { fields } = setupData(); const { result } = renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields, columnWidths: [], enabled: false, typographyCtx, - defaultHeight: 28, sortColumns: [], }); }); @@ -448,31 +444,20 @@ describe('TableNG hooks', () => { it('should return the default height when wrap is disabled', () => { const { fields } = setupData(); const { result } = renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields, columnWidths: [], enabled: true, typographyCtx, - defaultHeight: 28, sortColumns: [], }); }); - expect(result.current).toBe(22); + expect(result.current).toBe(28); }); it('should return the appropriate height for wrapped text', () => { - // Simulate 2 lines of text - jest.mocked(varPreLine).mockReturnValue({ - count: jest.fn(() => 2), - each: jest.fn(), - split: jest.fn(), - test: jest.fn(), - }); - const { fields } = setupData(); const { result } = renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields: fields.map((field) => { if (field.name === 'name') { @@ -492,8 +477,7 @@ describe('TableNG hooks', () => { }), columnWidths: [100, 100, 100], enabled: true, - typographyCtx: { ...typographyCtx, avgCharWidth: 5 }, - defaultHeight: 28, + typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) }, sortColumns: [], }); }); @@ -504,19 +488,9 @@ describe('TableNG hooks', () => { it('should calculate the available width for a header cell based on the icons rendered within it', () => { const countFn = jest.fn(() => 1); - // Simulate 2 lines of text - jest.mocked(varPreLine).mockReturnValue({ - count: countFn, - each: jest.fn(), - split: jest.fn(), - test: jest.fn(), - }); - const { fields } = setupData(); renderHook(() => { - const typographyCtx = useTypographyCtx(); - return useHeaderHeight({ fields: fields.map((field) => { if (field.name === 'name') { @@ -536,17 +510,15 @@ describe('TableNG hooks', () => { }), columnWidths: [100, 100, 100], enabled: true, - typographyCtx: { ...typographyCtx, avgCharWidth: 10 }, - defaultHeight: 28, + typographyCtx: { ...typographyCtx, wrappedCount: countFn }, sortColumns: [], showTypeIcons: false, }); }); - expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87); + expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86); renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields: fields.map((field) => { if (field.name === 'name') { @@ -567,14 +539,233 @@ describe('TableNG hooks', () => { }), columnWidths: [100, 100, 100], enabled: true, - typographyCtx: { ...typographyCtx, avgCharWidth: 10 }, - defaultHeight: 28, + typographyCtx: { ...typographyCtx, wrappedCount: countFn }, sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }], showTypeIcons: true, }); }); - expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27); + expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26); + }); + }); + + describe('useRowHeight', () => { + const typographyCtx = createTypographyContext(14, 'sans-serif'); + + it('returns the default height if there are no wrapped columns or nested frames', () => { + const { fields } = setupData(); + + const defaultHeight = 40; + + expect( + renderHook(() => { + return useRowHeight({ + fields, + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: false, + expandedRows: new Set(), + }); + }).result.current + ).toBe(defaultHeight); + }); + + describe('nested frames', () => { + it('returns 0 if the parent row is not expanded', () => { + const { fields } = setupData(); + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight: 40, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set(), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ __depth: 1, data: createDataFrame({ fields }), __index: 0 }); + }).result.current + ).toBe(0); + }); + + it('returns a static height if there are no rows in the nested frame', () => { + const { fields } = setupData(); + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight: 40, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set([0]), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ + __depth: 1, + data: undefined, + __index: 0, + }); + }).result.current + ).toBe(TABLE.NESTED_NO_DATA_HEIGHT + TABLE.CELL_PADDING * 2); + }); + + it('calculates the height to return based on the number of rows in the nested frame', () => { + const { fields } = setupData(); + + const defaultHeight = 40; + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set([0]), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ + __index: 0, + __depth: 1, + data: createDataFrame({ fields }), + }); + }).result.current + ).toBe(defaultHeight * 4 + TABLE.CELL_PADDING * 2); // 3 rows + header + padding + }); + + it('removes the header if configured', () => { + const { fields } = setupData(); + + const defaultHeight = 40; + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set([0]), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ + __index: 0, + __depth: 1, + data: createDataFrame({ fields, meta: { custom: { noHeader: true } } }), + }); + }).result.current + ).toBe(defaultHeight * 3 + TABLE.CELL_PADDING * 2); // 3 rows + padding (no header) + }); + }); + + // we test the lineCounters and getRowHeight directly to check that all of that + // math is working correctly. we mainly want to confirm here that the + // cache is clearing and that the local logic in this hook works. + describe('wrapped columns', () => { + let rows: TableRow[]; + let fieldsWithWrappedText: Field[]; + + beforeEach(() => { + const { fields, rows: _rows } = setupData(); + + rows = _rows; + fieldsWithWrappedText = fields.map((field) => { + if (field.name === 'name') { + return { + ...field, + name: 'Longer name that needs wrapping', + config: { + ...field.config, + custom: { + ...field.config?.custom, + cellOptions: { + cellType: TableCellDisplayMode.Auto, + wrapText: true, + }, + }, + }, + }; + } + return field; + }); + }); + + it('handles changes to default height on re-render', () => { + const { result, rerender } = renderHook( + ({ defaultHeight }) => { + const rowHeight = useRowHeight({ + fields: fieldsWithWrappedText, + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: false, + expandedRows: new Set(), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight; + }, + { + initialProps: { defaultHeight: 40 }, + } + ); + + expect(result.current(rows[0])).toBe(40); + + // change the column widths + rerender({ defaultHeight: 50 }); + + expect(result.current(rows[0])).toBe(50); + }); + + it('adjusts the width of the columns based on the cell padding and border', () => { + fieldsWithWrappedText[0].values[0] = 'Annie Lennox'; + + const wrappedCountFn = jest.fn(() => 2); + const estimateLinesFn = jest.fn(() => 2); + const { result } = renderHook(() => { + const rowHeight = useRowHeight({ + fields: fieldsWithWrappedText, + columnWidths: [100, 100, 100], + defaultHeight: 40, + typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn }, + hasNestedFrames: false, + expandedRows: new Set(), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight; + }); + + expect(result.current(rows[0])).toEqual(expect.any(Number)); + + expect(estimateLinesFn).toHaveBeenCalledWith('Annie Lennox', 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT); + }); }); }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index e5f30a68d27..70a790d9133 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -1,22 +1,20 @@ import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect, RefObject } from 'react'; import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid'; -import { varPreLine } from 'uwrap'; import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; -import { useTheme2 } from '../../../themes/ThemeContext'; -import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; +import { TableColumnResizeActionCallback } from '../types'; import { TABLE } from './constants'; -import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types'; +import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types'; import { getDisplayName, processNestedTableRows, applySort, - getCellOptions, getColumnTypes, - GetMaxWrapCellOptions, - getMaxWrapCell, + getRowHeight, + buildHeaderLineCounters, + buildRowLineCounters, } from './utils'; // Helper function to get displayed value @@ -314,49 +312,6 @@ export function useFooterCalcs( }, [fields, enabled, footerOptions, isCountRowsSet, rows]); } -interface TypographyCtx { - ctx: CanvasRenderingContext2D; - font: string; - avgCharWidth: number; - calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number; -} - -export function useTypographyCtx(): TypographyCtx { - const theme = useTheme2(); - const typographyCtx = useMemo((): TypographyCtx => { - const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - // set in grafana/data in createTypography.ts - const letterSpacing = 0.15; - - ctx.letterSpacing = `${letterSpacing}px`; - ctx.font = font; - const txt = - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"; - const txtWidth = ctx.measureText(txt).width; - const avgCharWidth = txtWidth / txt.length + letterSpacing; - const { count } = varPreLine(ctx); - - const calcRowHeight = (text: string, cellWidth: number, defaultHeight: number) => { - if (text === '') { - return defaultHeight; - } - const numLines = count(text, cellWidth); - const totalHeight = numLines * TABLE.LINE_HEIGHT + 2 * TABLE.CELL_PADDING; - return Math.max(totalHeight, defaultHeight); - }; - - return { - calcRowHeight, - ctx, - font, - avgCharWidth, - }; - }, [theme.typography.fontSize, theme.typography.fontFamily]); - return typographyCtx; -} - const ICON_WIDTH = 16; const ICON_GAP = 4; @@ -364,7 +319,6 @@ interface UseHeaderHeightOptions { enabled: boolean; fields: Field[]; columnWidths: number[]; - defaultHeight: number; sortColumns: SortColumn[]; typographyCtx: TypographyCtx; showTypeIcons?: boolean; @@ -374,12 +328,14 @@ export function useHeaderHeight({ fields, enabled, columnWidths, - defaultHeight, sortColumns, - typographyCtx: { calcRowHeight, avgCharWidth }, + typographyCtx, showTypeIcons = false, }: UseHeaderHeightOptions): number { const perIconSpace = ICON_WIDTH + ICON_GAP; + + const lineCounters = useMemo(() => buildHeaderLineCounters(fields, typographyCtx), [fields, typographyCtx]); + const columnAvailableWidths = useMemo( () => columnWidths.map((c, idx) => { @@ -396,46 +352,26 @@ export function useHeaderHeight({ if (showTypeIcons) { width -= perIconSpace; } - return Math.floor(width); + // sadly, the math for this is off by exactly 1 pixel. shrug. + return Math.floor(width) - 1; }), [fields, columnWidths, sortColumns, showTypeIcons, perIconSpace] ); - const [wrappedColHeaderIdxs, hasWrappedColHeaders] = useMemo(() => { - let hasWrappedColHeaders = false; - return [ - fields.map((field) => { - const wrapText = field.config?.custom?.wrapHeaderText ?? false; - if (wrapText) { - hasWrappedColHeaders = true; - } - return wrapText; - }), - hasWrappedColHeaders, - ]; - }, [fields]); - - const maxWrapCellOptions = useMemo( - () => ({ - colWidths: columnAvailableWidths, - avgCharWidth, - wrappedColIdxs: wrappedColHeaderIdxs, - }), - [columnAvailableWidths, avgCharWidth, wrappedColHeaderIdxs] - ); - - // TODO: is there a less clunky way to subtract the top padding value? const headerHeight = useMemo(() => { if (!enabled) { return 0; } - if (!hasWrappedColHeaders) { - return defaultHeight - TABLE.CELL_PADDING; - } - - const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, -1, maxWrapCellOptions); - return calcRowHeight(maxLinesText, columnAvailableWidths[maxLinesIdx], defaultHeight) - TABLE.CELL_PADDING; - }, [fields, enabled, hasWrappedColHeaders, maxWrapCellOptions, calcRowHeight, columnAvailableWidths, defaultHeight]); + return getRowHeight( + fields, + -1, + columnAvailableWidths, + TABLE.HEADER_HEIGHT, + lineCounters, + TABLE.LINE_HEIGHT, + TABLE.CELL_PADDING + ); + }, [fields, enabled, columnAvailableWidths, lineCounters]); return headerHeight; } @@ -455,42 +391,15 @@ export function useRowHeight({ hasNestedFrames, defaultHeight, expandedRows, - typographyCtx: { calcRowHeight, avgCharWidth }, + typographyCtx, }: UseRowHeightOptions): number | ((row: TableRow) => number) { - const [wrappedColIdxs, hasWrappedCols] = useMemo(() => { - let hasWrappedCols = false; - return [ - fields.map((field) => { - if (field.type !== FieldType.string) { - return false; - } + const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]); + const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]); - const cellOptions = getCellOptions(field); - const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText; - const type = cellOptions.type; - const result = !!wrapText && type !== TableCellDisplayMode.Image; - if (result === true) { - hasWrappedCols = true; - } - return result; - }), - hasWrappedCols, - ]; - }, [fields]); - - const colWidths = useMemo( - () => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT), - [columnWidths] - ); - - const maxWrapCellOptions = useMemo( - () => ({ - colWidths, - avgCharWidth, - wrappedColIdxs, - }), - [colWidths, avgCharWidth, wrappedColIdxs] - ); + const colWidths = useMemo(() => { + const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT; + return columnWidths.map((c) => c - columnWidthAffordance); + }, [columnWidths]); const rowHeight = useMemo(() => { // row height is only complicated when there are nested frames or wrapped columns. @@ -498,6 +407,9 @@ export function useRowHeight({ return defaultHeight; } + // this cache should get blown away on resize, data refresh, updated fields, etc. + // caching by __index is ok because sorting does not modify the __index. + const cache: Array = Array(fields[0].values.length); return (row: TableRow) => { // nested rows if (row.__depth > 0) { @@ -512,23 +424,25 @@ export function useRowHeight({ } const nestedHeaderHeight = row.data?.meta?.custom?.noHeader ? 0 : defaultHeight; - return Math.max(defaultHeight, defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2); + return defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2; } // regular rows - const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions); - return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight); + let result = cache[row.__index]; + if (!result) { + result = cache[row.__index] = getRowHeight( + fields, + row.__index, + colWidths, + defaultHeight, + lineCounters, + TABLE.LINE_HEIGHT, + TABLE.CELL_PADDING * 2 + ); + } + return result; }; - }, [ - calcRowHeight, - defaultHeight, - expandedRows, - fields, - hasNestedFrames, - hasWrappedCols, - maxWrapCellOptions, - colWidths, - ]); + }, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]); return rowHeight; } diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index bee921c1c71..1d736974189 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -261,3 +261,29 @@ export interface ScrollPosition { x: number; y: number; } + +export interface TypographyCtx { + ctx: CanvasRenderingContext2D; + font: string; + avgCharWidth: number; + estimateLines: LineCounter; + wrappedCount: LineCounter; +} + +export type LineCounter = (value: unknown, width: number) => number; +export interface LineCounterEntry { + /** + * given a values and the available width, returns the line count for that value + */ + counter: LineCounter; + /** + * if getting an accurate line count is expensive, you can provide an estimate method + * which will be used when looping over the row. the counter method will only be invoked + * for the cell which is the maximum line count for the row. + */ + estimate?: LineCounter; + /** + * indicates which field indexes of the visible fields this line counter applies to. + */ + fieldIdxs: number[]; +} 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 f7bfcf2cc9d..e86de2a7009 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -16,7 +16,8 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight } import { TableCellDisplayMode } from '../types'; -import { TABLE } from './constants'; +import { COLUMN, TABLE } from './constants'; +import { LineCounterEntry } from './types'; import { extractPixelValue, frameToRecords, @@ -31,8 +32,14 @@ import { getJustifyContent, migrateTableDisplayModeToCellOptions, getColumnTypes, - getMaxWrapCell, + computeColWidths, + getRowHeight, + buildRowLineCounters, + buildHeaderLineCounters, + getTextLineEstimator, + createTypographyContext, applySort, + SINGLE_LINE_ESTIMATE_THRESHOLD, } from './utils'; describe('TableNG utils', () => { @@ -975,117 +982,345 @@ describe('TableNG utils', () => { }); }); - describe('getMaxWrapCell', () => { - it('should return the maximum wrap cell length from field state', () => { - const field1: Field = { - name: 'field1', - type: FieldType.string, - config: {}, - values: ['beep boop', 'foo bar baz', 'lorem ipsum dolor sit amet'], - }; + describe('createTypographyCtx', () => { + // we can't test the effectiveness of this typography context in unit tests, only that it + // actually executed the JS correctly. If you called `count` with a sensible value and width, + // it wouldn't give you a very reasonable answer in Jest's DOM environment for some reason. + it('creates the context using uwrap', () => { + const ctx = createTypographyContext(14, 'sans-serif', 0.15); + expect(ctx).toEqual( + expect.objectContaining({ + font: '14px sans-serif', + ctx: expect.any(CanvasRenderingContext2D), + wrappedCount: expect.any(Function), + estimateLines: expect.any(Function), + avgCharWidth: expect.any(Number), + }) + ); + expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number)); + expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number)); + }); + }); - const field2: Field = { - name: 'field2', - type: FieldType.string, - config: {}, - values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''], - }; + describe('getTextLineEstimator', () => { + const counter = getTextLineEstimator(10); - const field3: Field = { - name: 'field3', - type: FieldType.string, - config: {}, - values: ['foo', 'bar', 'baz'], - // No alignmentFactors in state - }; - - const fields = [field1, field2, field3]; - - const result = getMaxWrapCell(fields, 0, { - colWidths: [30, 50, 100], - avgCharWidth: 5, - wrappedColIdxs: [true, true, true], - }); - expect(result).toEqual({ - text: 'asdfasdf asdfasdf asdfasdf', - idx: 1, - numLines: 2.6, - }); + it('returns -1 if there are no strings or dashes within the string', () => { + expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5)).toBe(-1); }); - it('should take colWidths into account when calculating max wrap cell', () => { + it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => { + expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200)).toBe(2.5); + }); + }); + + describe('buildHeaderLineCounters', () => { + const ctx = { + font: '14px sans-serif', + ctx: {} as CanvasRenderingContext2D, + count: jest.fn(() => 2), + avgCharWidth: 7, + wrappedCount: jest.fn(() => 2), + estimateLines: jest.fn(() => 2), + }; + + it('returns an array of line counters for each column', () => { const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } }, + ]; + const counters = buildHeaderLineCounters(fields, ctx); + expect(counters![0].counter).toEqual(expect.any(Function)); + expect(counters![0].fieldIdxs).toEqual([0, 1]); + }); + + it('does not return the index of columns which are not wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } }, + ]; + + const counters = buildHeaderLineCounters(fields, ctx); + expect(counters![0].fieldIdxs).toEqual([1]); + }); + + it('returns undefined if no columns are wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: {} } }, + ]; + + const counters = buildHeaderLineCounters(fields, ctx); + expect(counters).toBeUndefined(); + }); + }); + + describe('buildRowLineCounters', () => { + const ctx = { + font: '14px sans-serif', + ctx: {} as CanvasRenderingContext2D, + count: jest.fn(() => 2), + wrappedCount: jest.fn(() => 2), + estimateLines: jest.fn(() => 2), + avgCharWidth: 7, + }; + + it('returns an array of line counters for each column', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } }, { - name: 'field', + name: 'Address', type: FieldType.string, - config: {}, - values: ['short', 'a bit longer text'], + values: [], + config: { custom: { cellOptions: { wrapText: true } } }, }, + ]; + const counters = buildRowLineCounters(fields, ctx); + expect(counters![0].counter).toEqual(expect.any(Function)); + expect(counters![0].fieldIdxs).toEqual([0, 1]); + }); + + it('does not return the index of columns which are not wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, { - name: 'field', + name: 'Address', type: FieldType.string, - config: {}, - values: ['short', 'quite a bit longer text'], - }, - { - name: 'field', - type: FieldType.string, - config: {}, - values: ['short', 'less text'], + values: [], + config: { custom: { cellOptions: { wrapText: true } } }, }, ]; - // Simulate a narrow column width that would cause wrapping - const colWidths = [50, 1000, 30]; // 50px width - const avgCharWidth = 5; // Assume average character width is 5px - - const result = getMaxWrapCell(fields, 1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] }); - - // With a 50px width and 5px per character, we can fit 10 characters per line - // "the longest text in this field" has 31 characters, so it should wrap to 4 lines - expect(result).toEqual({ - idx: 0, - numLines: 1.7, - text: 'a bit longer text', - }); + const counters = buildRowLineCounters(fields, ctx); + expect(counters![0].fieldIdxs).toEqual([1]); }); - it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => { + it('does not enable text counting for non-string fields', () => { const fields: Field[] = [ - { - name: 'Field with a very long name', - type: FieldType.string, - config: {}, - values: ['short', 'a bit longer text'], - }, + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: { cellOptions: { wrapText: true } } } }, + ]; + + const counters = buildRowLineCounters(fields, ctx); + // empty array - we had one column that indicated it wraps, but it was numeric, so we just ignore it + expect(counters).toEqual([]); + }); + + it('returns an undefined if no columns are wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: {} } }, + ]; + + const counters = buildRowLineCounters(fields, ctx); + expect(counters).toBeUndefined(); + }); + }); + + describe('getRowHeight', () => { + let fields: Field[]; + let counters: LineCounterEntry[]; + + beforeEach(() => { + fields = [ { name: 'Name', type: FieldType.string, - config: {}, - values: ['short', 'quite a bit longer text'], + values: ['foo', 'bar', 'baz', 'longer one here', 'shorter'], + config: { custom: { cellOptions: { wrapText: true } } }, }, { - name: 'Another field', - type: FieldType.string, - config: {}, - values: ['short', 'less text'], + name: 'Age', + type: FieldType.number, + values: [1, 2, 3, 123456, 789122349932], + config: { custom: { cellOptions: { wrapText: true } } }, }, ]; - - // Simulate a narrow column width that would cause wrapping - const colWidths = [50, 1000, 30]; // 50px width - const avgCharWidth = 5; // Assume average character width is 5px - - const result = getMaxWrapCell(fields, -1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] }); - - // With a 50px width and 5px per character, we can fit 10 characters per line - // "the longest text in this field" has 31 characters, so it should wrap to 4 lines - expect(result).toEqual({ idx: 0, numLines: 2.7, text: 'Field with a very long name' }); + counters = [ + { counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines + { counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), fieldIdxs: [1] }, // Mocked to return a line for every 3 digits of a number + ]; }); - it.todo('should ignore columns which are not wrapped'); + it('should use the default height for single-line rows', () => { + // 1 line @ 20px, 10px vertical padding = 30, minimum is 36 + expect(getRowHeight(fields, 0, [30, 30], 36, counters, 20, 10)).toBe(36); + }); - it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)'); + it('should use the default height for multi-line rows which are shorter than the default height', () => { + // 3 lines @ 5px, 5px vertical padding = 20, minimum is 36 + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 5, 5)).toBe(36); + }); + + it('should return the row height using line counters for multi-line', () => { + // 3 lines @ 20px ('longer', 'one', 'here'), 10px vertical padding + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(70); + + // 4 lines @ 15px (789 122 349 932), 15px vertical padding + expect(getRowHeight(fields, 4, [30, 30], 36, counters, 15, 15)).toBe(75); + }); + + it('should take colWidths into account when calculating max wrap cell', () => { + getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10); + expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50); + expect(counters[1].counter).toHaveBeenCalledWith(123456, 60); + }); + + // this is used to calc wrapped header height + it('should use the display name if the rowIdx is -1', () => { + getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10); + expect(counters[0].counter).toHaveBeenCalledWith('Name', 50); + expect(counters[1].counter).toHaveBeenCalledWith('Age', 60); + }); + + it('should ignore columns which do not have line counters', () => { + const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10); + // 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name) + expect(height).toBe(50); + }); + + it('should return the default height if there are no counters to apply', () => { + const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10); + expect(height).toBe(36); + }); + + describe('estimations vs. precise counts', () => { + beforeEach(() => { + counters = [ + { counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines + { + estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number + counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), + fieldIdxs: [1], + }, + ]; + }); + + // 2 lines @ 20px (123,456), 10px vertical padding. when we did this before, 'longer one here' would win, making it 70px. + // the `estimate` function is picking `123456` as the longer one now (6 lines), then the `counter` function is used + // to calculate the height (2 lines). this is a very forced case, but we just want to prove that it actually works. + it('uses the estimate value rather than the precise value to select the row height', () => { + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50); + }); + + it('returns doesnt bother getting the precise count if the estimates are all below the threshold', () => { + jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3); + jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1); + + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(36); + + // this is what we really care about - we want to save on performance by not calling the counter in this case. + expect(counters[1].counter).not.toHaveBeenCalled(); + }); + + it('uses the precise count if the estimate is above the threshold, even if its below 1', () => { + // NOTE: if this fails, just change the test to use a different value besides 0.1 + expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(1); + + jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3); + jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1); + + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50); + }); + }); + }); + + describe('computeColWidths', () => { + it('returns the configured widths if all columns set them', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: { custom: { width: 100 } }, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: { custom: { width: 200 } }, + }, + ], + 500 + ) + ).toEqual([100, 200]); + }); + + it('fills the available space if a column has no width set', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: {}, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: { custom: { width: 200 } }, + }, + ], + 500 + ) + ).toEqual([300, 200]); + }); + + it('applies minimum width when auto width would dip below it', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: { custom: { minWidth: 100 } }, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: { custom: { minWidth: 100 } }, + }, + ], + 100 + ) + ).toEqual([100, 100]); + }); + + it('should use the global column default width when nothing is set', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: {}, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: {}, + }, + ], + // we have two columns but have set the table to the width of one default column. + COLUMN.DEFAULT_WIDTH + ) + ).toEqual([COLUMN.DEFAULT_WIDTH, COLUMN.DEFAULT_WIDTH]); + }); + }); + + describe('displayJsonValue', () => { + it.todo('should parse and then stringify string values'); + it.todo('should not throw for non-serializable string values'); + it.todo('should stringify non-string values'); + it.todo('should not throw for non-serializable non-string values'); }); describe('applySort', () => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 67f4bf497ed..a24a361bfda 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1,6 +1,7 @@ import { Property } from 'csstype'; import { SortColumn } from 'react-data-grid'; import tinycolor from 'tinycolor2'; +import { Count, varPreLine } from 'uwrap'; import { FieldType, @@ -25,7 +26,16 @@ import { getTextColorForAlphaBackground } from '../../../utils/colors'; import { TableCellOptions } from '../types'; import { COLUMN, TABLE } from './constants'; -import { CellColors, TableRow, ColumnTypes, FrameToRowsConverter, Comparator } from './types'; +import { + CellColors, + TableRow, + ColumnTypes, + FrameToRowsConverter, + Comparator, + TypographyCtx, + LineCounter, + LineCounterEntry, +} from './types'; /* ---------------------------- Cell calculations --------------------------- */ export type CellNumLinesCalculator = (text: string, cellWidth: number) => number; @@ -71,58 +81,190 @@ export function shouldTextWrap(field: Field): boolean { return Boolean(cellOptions?.wrapText); } -// matches characters which CSS -const spaceRegex = /[\s-]/; +/** + * @internal creates a typography context based on a font size and family. used to measure text + * and estimate size of text in cells. + */ +export function createTypographyContext(fontSize: number, fontFamily: string, letterSpacing = 0.15): TypographyCtx { + const font = `${fontSize}px ${fontFamily}`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; -export interface GetMaxWrapCellOptions { - colWidths: number[]; - avgCharWidth: number; - wrappedColIdxs: boolean[]; + ctx.letterSpacing = `${letterSpacing}px`; + ctx.font = font; + const txt = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s."; + const txtWidth = ctx.measureText(txt).width; + const avgCharWidth = txtWidth / txt.length + letterSpacing; + const { count } = varPreLine(ctx); + + return { + ctx, + font, + avgCharWidth, + estimateLines: getTextLineEstimator(avgCharWidth), + wrappedCount: wrapUwrapCount(count), + }; } /** * @internal - * loop through the fields and their values, determine which cell is going to determine the - * height of the row based on its content and width, and then return the text, index, and number of lines for that cell. */ -export function getMaxWrapCell( +export function wrapUwrapCount(count: Count): LineCounter { + return (value, width) => { + if (value == null) { + return 1; + } + + return count(String(value), width); + }; +} + +/** + * @internal returns a line counter which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth. + */ +export function getTextLineEstimator(avgCharWidth: number): LineCounter { + return (value, width) => { + if (!value) { + return -1; + } + + // we don't have string breaking enabled in the table, + // so an unbroken string is by definition a single line. + const strValue = String(value); + if (!spaceRegex.test(strValue)) { + return -1; + } + + const charsPerLine = width / avgCharWidth; + return strValue.length / charsPerLine; + }; +} + +/** + * @internal return a text line counter for every field which has wrapHeaderText enabled. + */ +export function buildHeaderLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined { + const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => { + if (field.config?.custom?.wrapHeaderText) { + acc.push(idx); + } + return acc; + }, []); + + if (wrappedColIdxs.length === 0) { + return undefined; + } + + // don't bother with estimating the line counts for the headers, because it's punishing + // when we get it wrong and there won't be that many compared to how many rows a table might contain. + return [{ counter: typographyCtx.wrappedCount, fieldIdxs: wrappedColIdxs }]; +} + +const spaceRegex = /[\s-]/; + +/** + * @internal return a text line counter for every field which has wrapHeaderText enabled. we do this once as we're rendering + * the table, and then getRowHeight uses the output of this to caluclate the height of each row. + */ +export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined { + const result: Record = {}; + let wrappedFields = 0; + + for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) { + const field = fields[fieldIdx]; + if (shouldTextWrap(field)) { + wrappedFields++; + // TODO: Pills, DataLinks, and JSON will have custom line counters here. + + // for string fields, we really want to find the longest field ahead of time to reduce the number of calls to `count`. + // calling `count` is going to get a perfectly accurate line count, but it is expensive, so we'd rather estimate the line + // count and call the counter only for the field which will take up the most space based on its + if (field.type === FieldType.string) { + result.textCounter = result.textCounter ?? { + counter: typographyCtx.wrappedCount, + estimate: typographyCtx.estimateLines, + fieldIdxs: [], + }; + result.textCounter.fieldIdxs.push(fieldIdx); + } + } + } + + if (wrappedFields === 0) { + return undefined; + } + + return Object.values(result); +} + +// in some cases, the estimator might return a value that is less than 1, but when measured by the counter, it actually +// realizes that it's a multi-line cell. to avoid this, we want to give a little buffer away from 1 before we fully trust +// the estimator to have told us that a cell is single-line. +export const SINGLE_LINE_ESTIMATE_THRESHOLD = 0.85; + +/** + * @internal + * loop through the fields and their values, determine which cell is going to determine the height of the row based + * on its content and width, and return the height in pixels of that row, with vertial padding applied. + */ +export function getRowHeight( fields: Field[], rowIdx: number, - { colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions -): { - text: string; - idx: number; - numLines: number; -} { - let maxLines = 1; - let maxLinesIdx = -1; - let maxLinesText = ''; + columnWidths: number[], + defaultHeight: number, + lineCounters?: LineCounterEntry[], + lineHeight = TABLE.LINE_HEIGHT, + verticalPadding = 0 +): number { + if (!lineCounters?.length) { + return defaultHeight; + } - // TODO: consider changing how we store this, using a record by column key instead of an array - for (let i = 0; i < colWidths.length; i++) { - if (wrappedColIdxs[i]) { - const field = fields[i]; + let maxLines = -1; + let maxValue = ''; + let maxWidth = 0; + let preciseCounter: LineCounter | undefined; + + for (const { estimate, counter, fieldIdxs } of lineCounters) { + // for some of the line counters, getting the precise count of the lines is expensive. those line counters + // set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will + // get the "true" value right before calculating the row height by hanging onto a reference to the counter fn. + const count = estimate ?? counter; + const isEstimating = estimate !== undefined; + + for (const fieldIdx of fieldIdxs) { + const field = fields[fieldIdx]; // special case: for the header, provide `-1` as the row index. - const cellTextRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx]; - - if (cellTextRaw != null) { - const cellText = String(cellTextRaw); - - if (spaceRegex.test(cellText)) { - const charsPerLine = colWidths[i] / avgCharWidth; - const approxLines = cellText.length / charsPerLine; - - if (approxLines > maxLines) { - maxLines = approxLines; - maxLinesIdx = i; - maxLinesText = cellText; - } + const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx]; + if (cellValueRaw != null) { + const colWidth = columnWidths[fieldIdx]; + const approxLines = count(cellValueRaw, colWidth); + if (approxLines > maxLines) { + maxLines = approxLines; + maxValue = cellValueRaw; + maxWidth = colWidth; + preciseCounter = isEstimating ? counter : undefined; } } } } - return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines }; + // if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust + // that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter. + if (maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) { + return defaultHeight; + } + + // if we finished this row height loop with an estimate, we need to call + // the `preciseCounter` method to get the exact line count. + if (preciseCounter !== undefined) { + maxLines = preciseCounter(maxValue, maxWidth); + } + + // we want a round number of lines for rendering + const totalHeight = Math.ceil(maxLines) * lineHeight + verticalPadding; + return Math.max(totalHeight, defaultHeight); } /** diff --git a/yarn.lock b/yarn.lock index 9ac783e535e..897c95579e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3869,7 +3869,7 @@ __metadata: typescript: "npm:5.8.3" uplot: "npm:1.6.32" uuid: "npm:11.1.0" - uwrap: "npm:0.1.1" + uwrap: "npm:0.1.2" webpack: "npm:5.97.1" peerDependencies: react: ^18.0.0 @@ -31787,10 +31787,10 @@ __metadata: languageName: node linkType: hard -"uwrap@npm:0.1.1": - version: 0.1.1 - resolution: "uwrap@npm:0.1.1" - checksum: 10/d5d02cb2f0e7fd997862913458d67e0c7fa9fd5bc1025baca9e183ac87046be9148942e59440fef8d01a34d5674c0395bb46b13e00359602ea3155b305466090 +"uwrap@npm:0.1.2": + version: 0.1.2 + resolution: "uwrap@npm:0.1.2" + checksum: 10/621d9d148d903410ef555739baca1ba84500d59a5028611bc2fca53313ffd6c9b870e25dc5cad73b41f18fe72879a3d94a6b8ebc5e141c84a94188ee1c483492 languageName: node linkType: hard