mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 00:33:35 +08:00
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>
This commit is contained in:
@ -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',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.text.secondary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
gap: theme.spacing(1),
|
||||
|
||||
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"
|
||||
},
|
||||
|
Reference in New Issue
Block a user