TableNG: Add option to wrap header text (#107338)

* TableNG: wrapped header text option

* reorganize variable names and code for easier reuse

* refine height math

* move empty string check to after null check

* add tests for useHeaderHeight

* maybe a bit faster

* cleanup to avoid creating fns and objects all the time, some tests

* fix unit test

* cell was using panel height

* add borders between header cells to make resizing more obvious

* fix tests

* changes from mob review

* jk

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
pull/107594/head
Paul Marbach 2 weeks ago committed by GitHub
parent 20f30462ad
commit dd6a231aad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/grafana-schema/src/common/common.gen.ts
  2. 2
      packages/grafana-schema/src/common/table.cue
  3. 1
      packages/grafana-schema/src/veneer/common.types.ts
  4. 54
      packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx
  5. 25
      packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx
  6. 74
      packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
  7. 3
      packages/grafana-ui/src/components/Table/TableNG/constants.ts
  8. 214
      packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts
  9. 236
      packages/grafana-ui/src/components/Table/TableNG/hooks.ts
  10. 119
      packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
  11. 114
      packages/grafana-ui/src/components/Table/TableNG/utils.ts
  12. 10
      public/app/plugins/panel/table/table-new/module.tsx
  13. 2
      public/locales/en-US/grafana.json

@ -964,6 +964,10 @@ export interface TableFieldOptions {
inspect: boolean;
minWidth?: number;
width?: number;
/**
* Enables text wrapping for the display name in the table header.
*/
wrapHeaderText?: boolean;
}
export const defaultTableFieldOptions: Partial<TableFieldOptions> = {

@ -105,5 +105,7 @@ TableFieldOptions: {
filterable?: bool
// Hides any header for a column, useful for columns that show some static content or buttons.
hideHeader?: bool
// Enables text wrapping for the display name in the table header.
wrapHeaderText?: bool
} @cuetsy(kind="interface")

@ -41,6 +41,7 @@ export * from '../common/common.gen';
export const defaultTableFieldOptions: raw.TableFieldOptions = {
align: 'auto',
inspect: false,
wrapHeaderText: false,
cellOptions: {
type: raw.TableCellDisplayMode.Auto,
},

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import React, { useEffect } from 'react';
import { Column, SortDirection } from 'react-data-grid';
import { Field, GrafanaTheme2 } from '@grafana/data';
@ -34,9 +34,10 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
crossFilterRows,
showTypeIcons,
}) => {
const styles = useStyles2(getStyles);
const displayName = useMemo(() => getDisplayName(field), [field]);
const filterable = useMemo(() => field.config.custom?.filterable ?? false, [field]);
const headerCellWrap = field.config.custom?.wrapHeaderText ?? false;
const styles = useStyles2(getStyles, headerCellWrap);
const displayName = getDisplayName(field);
const filterable = field.config.custom?.filterable ?? false;
// we have to remove/reset the filter if the column is not filterable
useEffect(() => {
@ -50,15 +51,18 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
}, [filterable, displayName, filter, setFilter]);
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<>
<span className={styles.headerCellLabel}>
{showTypeIcons && <Icon name={getFieldTypeIcon(field)} title={field?.type} size="sm" />}
{/* Used cached displayName if available, otherwise use the column name (nested tables) */}
<div>{getDisplayName(field)}</div>
{direction && (direction === 'ASC' ? <Icon name="arrow-up" size="lg" /> : <Icon name="arrow-down" size="lg" />)}
</span>
{showTypeIcons && (
<Icon className={styles.headerCellIcon} name={getFieldTypeIcon(field)} title={field?.type} size="sm" />
)}
<span className={styles.headerCellLabel}>{getDisplayName(field)}</span>
{direction && (
<Icon
className={cx(styles.headerCellIcon, styles.headerSortIcon)}
size="lg"
name={direction === 'ASC' ? 'arrow-up' : 'arrow-down'}
/>
)}
{filterable && (
<Filter
name={column.key}
@ -68,32 +72,34 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
field={field}
crossFilterOrder={crossFilterOrder}
crossFilterRows={crossFilterRows}
iconClassName={styles.headerCellIcon}
/>
)}
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = (theme: GrafanaTheme2, headerTextWrap?: boolean) => ({
headerCellLabel: css({
border: 'none',
padding: 0,
background: 'inherit',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: theme.typography.fontWeightMedium,
display: 'flex',
alignItems: 'center',
color: theme.colors.text.secondary,
gap: theme.spacing(1),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: headerTextWrap ? 'pre-line' : 'nowrap',
'&:hover': {
textDecoration: 'underline',
color: theme.colors.text.link,
},
}),
headerCellIcon: css({
marginBottom: theme.spacing(0.5),
alignSelf: 'flex-end',
color: theme.colors.text.secondary,
}),
headerSortIcon: css({
marginBottom: theme.spacing(0.25),
}),
});
export { HeaderCell };

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { useMemo, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
@ -19,9 +19,19 @@ interface Props {
field?: Field;
crossFilterOrder: string[];
crossFilterRows: { [key: string]: TableRow[] };
iconClassName?: string;
}
export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, crossFilterRows }: Props) => {
export const Filter = ({
name,
rows,
filter,
setFilter,
field,
crossFilterOrder,
crossFilterRows,
iconClassName,
}: Props) => {
const filterValue = filter[name]?.filtered;
// get rows for cross filtering
@ -42,13 +52,13 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder,
const ref = useRef<HTMLButtonElement>(null);
const [isPopoverVisible, setPopoverVisible] = useState<boolean>(false);
const styles = useStyles2(getStyles);
const filterEnabled = useMemo(() => Boolean(filterValue), [filterValue]);
const filterEnabled = Boolean(filterValue);
const [searchFilter, setSearchFilter] = useState(filter[name]?.searchFilter || '');
const [operator, setOperator] = useState<SelectableValue<string>>(filter[name]?.operator || REGEX_OPERATOR);
return (
<button
className={cx(styles.headerFilter, filterEnabled ? styles.filterIconEnabled : styles.filterIconDisabled)}
className={styles.headerFilter}
ref={ref}
type="button"
onClick={(ev) => {
@ -56,7 +66,7 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder,
ev.stopPropagation();
}}
>
<Icon name="filter" />
<Icon name="filter" className={cx(iconClassName, { [styles.filterIconEnabled]: filterEnabled })} />
{isPopoverVisible && ref.current && (
<Popover
content={
@ -93,13 +103,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
border: 'none',
label: 'headerFilter',
padding: 0,
alignSelf: 'flex-end',
}),
filterIconEnabled: css({
label: 'filterIconEnabled',
color: theme.colors.primary.text,
}),
filterIconDisabled: css({
label: 'filterIconDisabled',
color: theme.colors.text.disabled,
}),
});

@ -15,6 +15,7 @@ import {
import { DataHoverClearEvent, DataHoverEvent, Field, FieldType, GrafanaTheme2, ReducerID } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
import { ContextMenu } from '../../ContextMenu/ContextMenu';
@ -33,10 +34,11 @@ import {
useColumnResize,
useFilteredRows,
useFooterCalcs,
useHeaderHeight,
usePaginatedRows,
useRowHeight,
useSortedRows,
useTextWraps,
useTypographyCtx,
} from './hooks';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
import {
@ -48,11 +50,12 @@ import {
getVisibleFields,
shouldTextOverflow,
getApplyToRowBgFn,
getColumnTypes,
computeColWidths,
applySort,
getCellColors,
getCellOptions,
shouldTextWrap,
isCellInspectEnabled,
} from './utils';
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
@ -116,7 +119,6 @@ export function TableNG(props: TableNGProps) {
}, [isContextMenuOpen]);
const rows = useMemo(() => frameToRecords(data), [data]);
const columnTypes = useMemo(() => getColumnTypes(data.fields), [data.fields]);
const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]);
const {
@ -131,9 +133,10 @@ export function TableNG(props: TableNGProps) {
rows: sortedRows,
sortColumns,
setSortColumns,
} = useSortedRows(filteredRows, data.fields, { columnTypes, hasNestedFrames, initialSortBy });
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
const defaultRowHeight = useMemo(() => getDefaultRowHeight(theme, cellHeight), [theme, cellHeight]);
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm);
const [isInspecting, setIsInspecting] = useState(false);
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
@ -143,8 +146,26 @@ export function TableNG(props: TableNGProps) {
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width),
[width, hasNestedFrames]
);
const typographyCtx = useTypographyCtx();
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
const rowHeight = useRowHeight(widths, visibleFields, hasNestedFrames, defaultRowHeight, expandedRows);
const headerHeight = useHeaderHeight({
columnWidths: widths,
fields: visibleFields,
enabled: hasHeader,
defaultHeight: defaultHeaderHeight,
sortColumns,
showTypeIcons: showTypeIcons ?? false,
typographyCtx,
});
const rowHeight = useRowHeight({
columnWidths: widths,
fields: visibleFields,
hasNestedFrames,
defaultHeight: defaultRowHeight,
headerHeight,
expandedRows,
typographyCtx,
});
const {
rows: paginatedRows,
@ -158,13 +179,12 @@ export function TableNG(props: TableNGProps) {
enabled: enablePagination,
width: availableWidth,
height,
hasHeader,
hasFooter,
headerHeight,
footerHeight: hasFooter ? defaultRowHeight : 0,
rowHeight,
});
// Create a map of column key to text wrap
const textWraps = useTextWraps(data.fields);
const footerCalcs = useFooterCalcs(sortedRows, data.fields, { enabled: hasFooter, footerOptions, isCountRowsSet });
const applyToRowBgFn = useMemo(() => getApplyToRowBgFn(data.fields, theme) ?? undefined, [data.fields, theme]);
@ -217,16 +237,16 @@ export function TableNG(props: TableNGProps) {
sortColumns,
rowHeight,
headerRowClass: styles.headerRow,
headerRowHeight: noHeader ? 0 : TABLE.HEADER_ROW_HEIGHT,
headerRowHeight: headerHeight,
bottomSummaryRows: hasFooter ? [{}] : undefined,
}) satisfies Partial<DataGridProps<TableRow, TableSummaryRow>>,
[
enableVirtualization,
resizeHandler,
sortColumns,
rowHeight,
headerHeight,
styles.headerRow,
noHeader,
rowHeight,
hasFooter,
setSortColumns,
onSortByChange,
@ -253,7 +273,7 @@ export function TableNG(props: TableNGProps) {
const cellOptions = getCellOptions(field);
const renderFieldCell = getCellRenderer(field, cellOptions);
const cellInspect = Boolean(field.config.custom?.inspect);
const cellInspect = isCellInspectEnabled(field);
const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null);
const showActions = cellInspect || showFilters;
const width = widths[i];
@ -269,9 +289,8 @@ export function TableNG(props: TableNGProps) {
: undefined;
const cellType = cellOptions.type;
const fieldType = columnTypes[displayName];
const shouldWrap = textWraps[displayName];
const shouldOverflow = shouldTextOverflow(fieldType, cellType, shouldWrap, cellInspect);
const shouldOverflow = shouldTextOverflow(field);
const shouldWrap = shouldTextWrap(field);
let lastRowIdx = -1;
let _rowHeight = 0;
@ -327,7 +346,7 @@ export function TableNG(props: TableNGProps) {
cellOptions,
frame,
field,
height,
height: _rowHeight,
justifyContent,
rowIdx,
theme,
@ -361,7 +380,7 @@ export function TableNG(props: TableNGProps) {
width,
headerCellClass,
renderCell: renderCellContent,
renderHeaderCell: ({ column, sortDirection }): JSX.Element => (
renderHeaderCell: ({ column, sortDirection }) => (
<HeaderCell
column={column}
rows={rows}
@ -474,6 +493,7 @@ export function TableNG(props: TableNGProps) {
return result;
}, [
applyToRowBgFn,
availableWidth,
commonDataGridProps,
crossFilterOrder,
@ -490,8 +510,8 @@ export function TableNG(props: TableNGProps) {
onCellFilterAdded,
panelContext,
replaceVariables,
rows,
rowHeight,
rows,
setFilter,
showTypeIcons,
sortColumns,
@ -499,10 +519,6 @@ export function TableNG(props: TableNGProps) {
theme,
visibleFields,
widths,
applyToRowBgFn,
columnTypes,
height,
textWraps,
]);
// invalidate columns on every structureRev change. this supports width editing in the fieldConfig.
@ -698,7 +714,11 @@ const getGridStyles = (
headerRow: css({
paddingBlockStart: 0,
fontWeight: 'normal',
...(noHeader && { display: 'none' }),
...(noHeader ? { display: 'none' } : {}),
'& .rdg-cell': {
height: '100%',
alignItems: 'flex-end',
},
}),
paginationContainer: css({
alignItems: 'center',
@ -736,9 +756,11 @@ const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.Just
gap: theme.spacing(0.5),
zIndex: theme.zIndex.tooltip - 1,
paddingInline: TABLE.CELL_PADDING,
paddingBlock: TABLE.CELL_PADDING,
borderInlineEnd: 'none',
paddingBlockEnd: TABLE.CELL_PADDING,
justifyContent,
'&:last-child': {
borderInlineEnd: 'none',
},
}),
});

@ -9,9 +9,10 @@ export const COLUMN = {
/** Table layout and display constants */
export const TABLE = {
CELL_PADDING: 6,
HEADER_ROW_HEIGHT: 28,
MAX_CELL_HEIGHT: 48,
PAGINATION_LIMIT: 750,
SCROLL_BAR_WIDTH: 8,
SCROLL_BAR_MARGIN: 2,
LINE_HEIGHT: 22,
BORDER_RIGHT: 0.666667,
};

@ -1,9 +1,23 @@
import { act, renderHook } from '@testing-library/react';
import { varPreLine } from 'uwrap';
import { Field, FieldType } from '@grafana/data';
import { useFilteredRows, usePaginatedRows, useSortedRows, useFooterCalcs } from './hooks';
import { getColumnTypes } from './utils';
import {
useFilteredRows,
usePaginatedRows,
useSortedRows,
useFooterCalcs,
useHeaderHeight,
useTypographyCtx,
} from './hooks';
jest.mock('uwrap', () => ({
// ...jest.requireActual('uwrap'),
varPreLine: jest.fn(() => ({
count: jest.fn(() => 1),
})),
}));
describe('TableNG hooks', () => {
function setupData() {
@ -87,10 +101,8 @@ describe('TableNG hooks', () => {
describe('useSortedRows', () => {
it('should correctly set up the table with an initial sort', () => {
const { fields, rows } = setupData();
const columnTypes = getColumnTypes(fields);
const { result } = renderHook(() =>
useSortedRows(rows, fields, {
columnTypes,
hasNestedFrames: false,
initialSortBy: [{ displayName: 'age', desc: false }],
})
@ -103,10 +115,8 @@ describe('TableNG hooks', () => {
it('should change the sort on setSortColumns', () => {
const { fields, rows } = setupData();
const columnTypes = getColumnTypes(fields);
const { result } = renderHook(() =>
useSortedRows(rows, fields, {
columnTypes,
hasNestedFrames: false,
initialSortBy: [{ displayName: 'age', desc: false }],
})
@ -134,7 +144,14 @@ describe('TableNG hooks', () => {
it('should return defaults for pagination values when pagination is disabled', () => {
const { rows } = setupData();
const { result } = renderHook(() =>
usePaginatedRows(rows, { rowHeight: 30, height: 300, width: 800, enabled: false })
usePaginatedRows(rows, {
rowHeight: 30,
height: 300,
width: 800,
enabled: false,
headerHeight: 28,
footerHeight: 0,
})
);
expect(result.current.page).toBe(-1);
@ -153,6 +170,39 @@ describe('TableNG hooks', () => {
height: 60,
width: 800,
rowHeight: 10,
headerHeight: 0,
footerHeight: 0,
})
);
expect(result.current.page).toBe(0);
expect(result.current.rowsPerPage).toBe(2);
expect(result.current.pageRangeStart).toBe(1);
expect(result.current.pageRangeEnd).toBe(2);
expect(result.current.rows.length).toBe(2);
act(() => {
result.current.setPage(1);
});
expect(result.current.page).toBe(1);
expect(result.current.rowsPerPage).toBe(2);
expect(result.current.pageRangeStart).toBe(3);
expect(result.current.pageRangeEnd).toBe(3);
expect(result.current.rows.length).toBe(1);
});
it('should handle header and footer correctly', () => {
// with the numbers provided here, we have 3 rows, with 2 rows per page, over 2 pages total.
const { rows } = setupData();
const { result } = renderHook(() =>
usePaginatedRows(rows, {
enabled: true,
height: 140,
width: 800,
rowHeight: 10,
headerHeight: 28,
footerHeight: 45,
})
);
@ -332,4 +382,154 @@ describe('TableNG hooks', () => {
expect(result.current).toEqual(['Total', '6', '13']);
});
});
describe('useHeaderHeight', () => {
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: [],
});
});
expect(result.current).toBe(0);
});
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);
});
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') {
return {
...field,
name: 'Longer name that needs wrapping',
config: {
...field.config,
custom: {
...field.config?.custom,
wrapHeaderText: true,
},
},
};
}
return field;
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 5 },
defaultHeight: 28,
sortColumns: [],
});
});
expect(result.current).toBe(50);
});
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') {
return {
...field,
name: 'Longer name that needs wrapping',
config: {
...field.config,
custom: {
...field.config?.custom,
wrapHeaderText: true,
},
},
};
}
return field;
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
defaultHeight: 28,
sortColumns: [],
showTypeIcons: false,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87);
renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
return {
...field,
name: 'Longer name that needs wrapping',
config: {
...field.config,
custom: {
...field.config?.custom,
filterable: true,
wrapHeaderText: true,
},
},
};
}
return field;
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
defaultHeight: 28,
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
showTypeIcons: true,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27);
});
});
});

@ -1,5 +1,6 @@
import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
import { Column, DataGridProps, SortColumn } from 'react-data-grid';
import { varPreLine } from 'uwrap';
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
@ -7,8 +8,16 @@ import { useTheme2 } from '../../../themes/ThemeContext';
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { ColumnTypes, FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
import { getDisplayName, processNestedTableRows, getCellHeightCalculator, applySort, getCellOptions } from './utils';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
import {
getDisplayName,
processNestedTableRows,
applySort,
getCellOptions,
getColumnTypes,
GetMaxWrapCellOptions,
getMaxWrapCell,
} from './utils';
// Helper function to get displayed value
const getDisplayedValue = (row: TableRow, key: string, fields: Field[]) => {
@ -79,7 +88,6 @@ export function useFilteredRows(
}
export interface SortedRowsOptions {
columnTypes: ColumnTypes;
hasNestedFrames: boolean;
initialSortBy?: TableSortByFieldState[];
}
@ -93,7 +101,7 @@ export interface SortedRowsResult {
export function useSortedRows(
rows: TableRow[],
fields: Field[],
{ initialSortBy, columnTypes, hasNestedFrames }: SortedRowsOptions
{ initialSortBy, hasNestedFrames }: SortedRowsOptions
): SortedRowsResult {
const initialSortColumns = useMemo<SortColumn[]>(
() =>
@ -111,6 +119,7 @@ export function useSortedRows(
[] // eslint-disable-line react-hooks/exhaustive-deps
);
const [sortColumns, setSortColumns] = useState<SortColumn[]>(initialSortColumns);
const columnTypes = useMemo(() => getColumnTypes(fields), [fields]);
const sortedRows = useMemo(
() => applySort(rows, fields, sortColumns, columnTypes, hasNestedFrames),
@ -128,8 +137,8 @@ export interface PaginatedRowsOptions {
height: number;
width: number;
rowHeight: number | ((row: TableRow) => number);
hasHeader?: boolean;
hasFooter?: boolean;
headerHeight: number;
footerHeight: number;
paginationHeight?: number;
enabled?: boolean;
}
@ -150,7 +159,7 @@ const PAGINATION_HEIGHT = 38;
export function usePaginatedRows(
rows: TableRow[],
{ height, width, hasHeader, hasFooter, rowHeight, enabled }: PaginatedRowsOptions
{ height, width, headerHeight, footerHeight, rowHeight, enabled }: PaginatedRowsOptions
): PaginatedRowsResult {
// TODO: allow persisted page selection via url
const [page, setPage] = useState(0);
@ -177,8 +186,7 @@ export function usePaginatedRows(
}
// calculate number of rowsPerPage based on height stack
const rowAreaHeight =
height - (hasHeader ? TABLE.HEADER_ROW_HEIGHT : 0) - (hasFooter ? avgRowHeight : 0) - PAGINATION_HEIGHT;
const rowAreaHeight = height - headerHeight - footerHeight - PAGINATION_HEIGHT;
const heightPerRow = Math.floor(rowAreaHeight / (avgRowHeight || 1));
// ensure at least one row per page is displayed
let rowsPerPage = heightPerRow > 1 ? heightPerRow : 1;
@ -198,7 +206,7 @@ export function usePaginatedRows(
pageRangeEnd,
smallPagination,
};
}, [width, height, hasHeader, hasFooter, avgRowHeight, enabled, numRows, page]);
}, [width, height, headerHeight, footerHeight, avgRowHeight, enabled, numRows, page]);
// safeguard against page overflow on panel resize or other factors
useEffect(() => {
@ -294,22 +302,16 @@ export function useFooterCalcs(
}, [fields, enabled, footerOptions, isCountRowsSet, rows]);
}
export function useTextWraps(fields: Field[]): Record<string, boolean> {
return useMemo(
() =>
fields.reduce<{ [key: string]: boolean }>((acc, field) => {
const cellOptions = getCellOptions(field);
const displayName = getDisplayName(field);
const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText;
return { ...acc, [displayName]: !!wrapText };
}, {}),
[fields]
);
interface TypographyCtx {
ctx: CanvasRenderingContext2D;
font: string;
avgCharWidth: number;
calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number;
}
export function useTypographyCtx() {
export function useTypographyCtx(): TypographyCtx {
const theme = useTheme2();
const { ctx, font, avgCharWidth } = useMemo(() => {
const typographyCtx = useMemo((): TypographyCtx => {
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
@ -322,23 +324,128 @@ export function useTypographyCtx() {
"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 { ctx, font, avgCharWidth };
return typographyCtx;
}
export function useRowHeight(
columnWidths: number[],
fields: Field[],
hasNestedFrames: boolean,
defaultRowHeight: number,
expandedRows: Record<string, boolean>
): number | ((row: TableRow) => number) {
const ICON_WIDTH = 16;
const ICON_GAP = 4;
interface UseHeaderHeightOptions {
enabled: boolean;
fields: Field[];
columnWidths: number[];
defaultHeight: number;
sortColumns: SortColumn[];
typographyCtx: TypographyCtx;
showTypeIcons?: boolean;
}
export function useHeaderHeight({
fields,
enabled,
columnWidths,
defaultHeight,
sortColumns,
typographyCtx: { calcRowHeight, avgCharWidth },
showTypeIcons = false,
}: UseHeaderHeightOptions): number {
const perIconSpace = ICON_WIDTH + ICON_GAP;
const columnAvailableWidths = useMemo(
() =>
columnWidths.map((c, idx) => {
let width = c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT;
// filtering icon
if (fields[idx]?.config?.custom?.filterable) {
width -= perIconSpace;
}
// sorting icon
if (sortColumns.some((col) => col.columnKey === getDisplayName(fields[idx]))) {
width -= perIconSpace;
}
// type icon
if (showTypeIcons) {
width -= perIconSpace;
}
return Math.floor(width);
}),
[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<GetMaxWrapCellOptions>(
() => ({
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 headerHeight;
}
interface UseRowHeightOptions {
columnWidths: number[];
fields: Field[];
hasNestedFrames: boolean;
defaultHeight: number;
headerHeight: number;
expandedRows: Record<string, boolean>;
typographyCtx: TypographyCtx;
}
export function useRowHeight({
columnWidths,
fields,
hasNestedFrames,
defaultHeight,
headerHeight,
expandedRows,
typographyCtx: { calcRowHeight, avgCharWidth },
}: UseRowHeightOptions): number | ((row: TableRow) => number) {
const [wrappedColIdxs, hasWrappedCols] = useMemo(() => {
let hasWrappedCols = false;
return [
@ -360,22 +467,26 @@ export function useRowHeight(
];
}, [fields]);
const { ctx, avgCharWidth } = useTypographyCtx();
const colWidths = useMemo(
() => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT),
[columnWidths]
);
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
() => ({
colWidths,
avgCharWidth,
wrappedColIdxs,
}),
[colWidths, avgCharWidth, wrappedColIdxs]
);
const rowHeight = useMemo(() => {
// row height is only complicated when there are nested frames or wrapped columns.
if (!hasNestedFrames && !hasWrappedCols) {
return defaultRowHeight;
return defaultHeight;
}
const HPADDING = TABLE.CELL_PADDING;
const VPADDING = TABLE.CELL_PADDING;
const BORDER_RIGHT = 0.666667;
const LINE_HEIGHT = 22;
const wrapWidths = columnWidths.map((c) => c - 2 * HPADDING - BORDER_RIGHT);
const calc = getCellHeightCalculator(ctx, LINE_HEIGHT, defaultRowHeight, VPADDING);
return (row: TableRow) => {
// nested rows
if (Number(row.__depth) > 0) {
@ -384,50 +495,25 @@ export function useRowHeight(
return 0;
}
// Ensure we have a minimum height (defaultRowHeight) for the nested table even if data is empty
const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1;
// Ensure we have a minimum height (defaultHeight) for the nested table even if data is empty
const rowCount = row.data?.length ?? 0;
return Math.max(defaultRowHeight, defaultRowHeight * (rowCount + headerCount));
return Math.max(defaultHeight, defaultHeight * rowCount + headerHeight);
}
// regular rows
let maxLines = 1;
let maxLinesIdx = -1;
let maxLinesText = '';
for (let i = 0; i < columnWidths.length; i++) {
if (wrappedColIdxs[i]) {
const cellTextRaw = fields[i].values[row.__index];
if (cellTextRaw != null) {
const cellText = String(cellTextRaw);
const charsPerLine = wrapWidths[i] / avgCharWidth;
const approxLines = cellText.length / charsPerLine;
if (approxLines > maxLines) {
maxLines = approxLines;
maxLinesIdx = i;
maxLinesText = cellText;
}
}
}
}
if (maxLinesIdx === -1) {
return defaultRowHeight;
}
return calc(maxLinesText, wrapWidths[maxLinesIdx]);
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions);
return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight);
};
}, [
avgCharWidth,
columnWidths,
ctx,
defaultRowHeight,
calcRowHeight,
defaultHeight,
expandedRows,
fields,
hasNestedFrames,
hasWrappedCols,
wrappedColIdxs,
headerHeight,
maxWrapCellOptions,
colWidths,
]);
return rowHeight;

@ -28,6 +28,7 @@ import {
getTextAlign,
migrateTableDisplayModeToCellOptions,
getColumnTypes,
getMaxWrapCell,
} from './utils';
describe('TableNG utils', () => {
@ -974,11 +975,6 @@ describe('TableNG utils', () => {
});
});
describe('getCellHeightCalculator', () => {
it.todo('returns a cell height calculator');
it.todo('returns a minimum height of the default row height');
});
describe('getDefaultRowHeight', () => {
const theme = createTheme();
@ -1006,4 +1002,117 @@ describe('TableNG utils', () => {
expect(result).toBe(expected);
});
});
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'],
};
const field2: Field = {
name: 'field2',
type: FieldType.string,
config: {},
values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''],
};
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('should take colWidths into account when calculating max wrap cell', () => {
const fields: Field[] = [
{
name: 'field',
type: FieldType.string,
config: {},
values: ['short', 'a bit longer text'],
},
{
name: 'field',
type: FieldType.string,
config: {},
values: ['short', 'quite a bit longer text'],
},
{
name: 'field',
type: FieldType.string,
config: {},
values: ['short', 'less text'],
},
];
// 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',
});
});
it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => {
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,
config: {},
values: ['short', 'quite a bit longer text'],
},
{
name: 'Another field',
type: FieldType.string,
config: {},
values: ['short', 'less text'],
},
];
// 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' });
});
it.todo('should ignore columns which are not wrapped');
it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)');
});
});

@ -1,7 +1,6 @@
import { Property } from 'csstype';
import { SortColumn } from 'react-data-grid';
import tinycolor from 'tinycolor2';
import { varPreLine } from 'uwrap';
import {
FieldType,
@ -29,26 +28,6 @@ import { CellColors, TableRow, TableFieldOptionsType, ColumnTypes, FrameToRowsCo
/* ---------------------------- Cell calculations --------------------------- */
export type CellHeightCalculator = (text: string, cellWidth: number) => number;
/**
* @internal
* Returns a function that calculates the height of a cell based on its text content and width.
*/
export function getCellHeightCalculator(
// should be pre-configured with font and letterSpacing
ctx: CanvasRenderingContext2D,
lineHeight: number,
defaultRowHeight: number,
padding = 0
) {
const { count } = varPreLine(ctx);
return (text: string, cellWidth: number) => {
const numLines = count(text, cellWidth);
const totalHeight = numLines * lineHeight + 2 * padding;
return Math.max(totalHeight, defaultRowHeight);
};
}
/**
* @internal
* Returns the default row height based on the theme and cell height setting.
@ -69,19 +48,94 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCell
return TABLE.CELL_PADDING * 2 + bodyFontSize * lineHeight;
}
/**
* @internal
* Returns true if cell inspection (hover to see full content) is enabled for the field.
*/
export function isCellInspectEnabled(field: Field): boolean {
return field.config?.custom?.inspect ?? false;
}
/**
* @internal
* Returns true if text wrapping should be applied to the cell.
*/
export function shouldTextWrap(field: Field): boolean {
const cellOptions = getCellOptions(field);
// @ts-ignore - a handful of cellTypes have boolean wrapText, but not all of them.
// we should be very careful to only use boolean type for cellOptions.wrapText.
// TBH we will probably move this up to a field option which is showIf rendered anyway,
// but that'll be a migration to do, so it needs to happen post-GA.
return Boolean(cellOptions?.wrapText);
}
// matches characters which CSS
const spaceRegex = /[\s-]/;
export interface GetMaxWrapCellOptions {
colWidths: number[];
avgCharWidth: number;
wrappedColIdxs: boolean[];
}
/**
* @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(
fields: Field[],
rowIdx: number,
{ colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions
): {
text: string;
idx: number;
numLines: number;
} {
let maxLines = 1;
let maxLinesIdx = -1;
let maxLinesText = '';
// 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];
// 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;
}
}
}
}
}
return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines };
}
/**
* @internal
* Returns true if text overflow handling should be applied to the cell.
*/
export function shouldTextOverflow(
fieldType: FieldType,
cellType: TableCellDisplayMode,
textWrap: boolean,
cellInspect: boolean
): boolean {
// Tech debt: Technically image cells are of type string, which is misleading (kinda?)
// so we need to ensure we don't apply overflow hover states fo type image
return fieldType === FieldType.string && cellType !== TableCellDisplayMode.Image && !textWrap && !cellInspect;
export function shouldTextOverflow(field: Field): boolean {
return (
field.type === FieldType.string &&
// Tech debt: Technically image cells are of type string, which is misleading (kinda?)
// so we need to ensure we don't apply overflow hover states for type image
getCellOptions(field).type !== TableCellDisplayMode.Image &&
!shouldTextWrap(field) &&
!isCellInspectEnabled(field)
);
}
/**

@ -102,6 +102,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
description: t('table-new.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable,
})
.addBooleanSwitch({
path: 'wrapHeaderText',
name: t('table.name-wrap-header-text', 'Wrap header text'),
description: t(
'table.description-wrap-header-text',
'Enables text wrapping for the field name in the table header'
),
category,
defaultValue: defaultTableFieldOptions.wrapHeaderText,
})
.addBooleanSwitch({
path: 'hidden',
name: t('table-new.name-hide-in-table', 'Hide in table'),

@ -11388,6 +11388,7 @@
"description-count-rows": "Display a single count for all data rows",
"description-fields": "Select the fields that should be calculated",
"description-min-column-width": "The minimum width for column auto resizing",
"description-wrap-header-text": "Enables text wrapping for the field name in the table header",
"image-cell-options-editor": {
"description-alt-text": "Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader",
"description-title-text": "Text that will be displayed when the image is hovered by a cursor",
@ -11408,6 +11409,7 @@
"name-min-column-width": "Minimum column width",
"name-show-table-footer": "Show table footer",
"name-show-table-header": "Show table header",
"name-wrap-header-text": "Wrap header text",
"placeholder-column-width": "auto",
"placeholder-fields": "All Numeric Fields"
},

Loading…
Cancel
Save