From dd6a231aad16227cc59b1688f85ff46110ff7c62 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 3 Jul 2025 14:21:58 -0500 Subject: [PATCH] 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 --- .../grafana-schema/src/common/common.gen.ts | 4 + packages/grafana-schema/src/common/table.cue | 2 + .../grafana-schema/src/veneer/common.types.ts | 1 + .../Table/TableNG/Cells/HeaderCell.tsx | 54 ++-- .../Table/TableNG/Filter/Filter.tsx | 25 +- .../src/components/Table/TableNG/TableNG.tsx | 74 ++++-- .../src/components/Table/TableNG/constants.ts | 3 +- .../components/Table/TableNG/hooks.test.ts | 214 +++++++++++++++- .../src/components/Table/TableNG/hooks.ts | 236 ++++++++++++------ .../components/Table/TableNG/utils.test.ts | 119 ++++++++- .../src/components/Table/TableNG/utils.ts | 114 ++++++--- .../plugins/panel/table/table-new/module.tsx | 10 + public/locales/en-US/grafana.json | 2 + 13 files changed, 681 insertions(+), 177 deletions(-) diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 7986476fa56..9e1d0fd99a8 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -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 = { diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 7548275ada7..bdb537c8504 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -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") diff --git a/packages/grafana-schema/src/veneer/common.types.ts b/packages/grafana-schema/src/veneer/common.types.ts index 8a73c1149f9..fb3bfe18c57 100644 --- a/packages/grafana-schema/src/veneer/common.types.ts +++ b/packages/grafana-schema/src/veneer/common.types.ts @@ -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, }, diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx index c5f7b79400f..a62cbeb6beb 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx @@ -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 = ({ 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 = ({ }, [filterable, displayName, filter, setFilter]); return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions <> - - {showTypeIcons && } - {/* Used cached displayName if available, otherwise use the column name (nested tables) */} -
{getDisplayName(field)}
- {direction && (direction === 'ASC' ? : )} -
- + {showTypeIcons && ( + + )} + {getDisplayName(field)} + {direction && ( + + )} {filterable && ( = ({ 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 }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx index acde579e401..6f2c55fc3d7 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx @@ -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(null); const [isPopoverVisible, setPopoverVisible] = useState(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>(filter[name]?.operator || REGEX_OPERATOR); return (