Table: Prevent re-created functions and arrays (#109929)

* Table: Button up re-created functions and arrays

* use the single styles object

* restore automatically adjusted line

* restore automatically adjusted line

* update computeColWidths to create a string to help compare col widths

* use compareArrayVals for widths comparisons

* satisfy linter

* whoops, had this backwards

* clean up case with frozen columns

* remove dep

* avoid unnecessary change to computeColWidths
This commit is contained in:
Paul Marbach
2025-08-21 15:22:30 -04:00
committed by GitHub
parent 54f741f7a5
commit e91c12db64
5 changed files with 303 additions and 218 deletions
@@ -9,6 +9,7 @@ import {
DataGridHandle,
DataGridProps,
RenderCellProps,
Renderers,
RenderRowProps,
Row,
SortColumn,
@@ -42,6 +43,7 @@ import { TableCellTooltip } from './components/TableCellTooltip';
import { COLUMN, TABLE } from './constants';
import {
useColumnResize,
useColWidths,
useFilteredRows,
useFooterCalcs,
useHeaderHeight,
@@ -51,6 +53,7 @@ import {
useSortedRows,
} from './hooks';
import {
getCellActionStyles,
getDefaultCellStyles,
getFooterStyles,
getGridStyles,
@@ -58,11 +61,19 @@ import {
getLinkStyles,
getTooltipStyles,
} from './styles';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, InspectCellProps, TableCellStyleOptions } from './types';
import {
TableNGProps,
TableRow,
TableSummaryRow,
TableColumn,
InspectCellProps,
TableCellStyleOptions,
FromFieldsResult,
CellRootRenderer,
} from './types';
import {
applySort,
canFieldBeColorized,
computeColWidths,
createTypographyContext,
displayJsonValue,
extractPixelValue,
@@ -84,7 +95,7 @@ import {
withDataLinksActionsTooltip,
} from './utils';
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
const EXPANDED_COLUMN_KEY = 'expanded';
export function TableNG(props: TableNGProps) {
const {
@@ -148,6 +159,7 @@ export function TableNG(props: TableNGProps) {
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
const [inspectCell, setInspectCell] = useState<InspectCellProps | null>(null);
const [tooltipState, setTooltipState] = useState<DataLinksActionsTooltipState>();
const [expandedRows, setExpandedRows] = useState(() => new Set<number>());
// vt scrollbar accounting for column auto-sizing
@@ -171,20 +183,9 @@ export function TableNG(props: TableNGProps) {
),
[theme]
);
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
const numColsFullyInView = useMemo(
() =>
widths.reduce(
([count, remainingWidth], nextWidth) => {
if (remainingWidth - nextWidth >= 0) {
return [count + 1, remainingWidth - nextWidth];
}
return [count, 0];
},
[0, availableWidth]
)[0],
[widths, availableWidth]
);
const [widths, numFrozenColsFullyInView] = useColWidths(visibleFields, availableWidth, frozenColumns);
const headerHeight = useHeaderHeight({
columnWidths: widths,
fields: visibleFields,
@@ -270,15 +271,84 @@ export function TableNG(props: TableNGProps) {
[enableVirtualization, resizeHandler, sortColumns, rowHeight, hasFooter, setSortColumns, onSortByChange]
);
interface Schema {
columns: TableColumn[];
cellRootRenderers: Record<string, CellRootRenderer>;
colsWithTooltip: Record<string, boolean>;
}
const buildNestedTableExpanderColumn = useCallback(
(
nestedColumns: TableColumn[],
hasNestedHeaders: boolean,
renderers: Renderers<TableRow, TableSummaryRow>
): TableColumn => ({
key: EXPANDED_COLUMN_KEY,
name: '',
field: {
name: '',
type: FieldType.other,
config: {},
values: [],
},
cellClass(row) {
if (row.__depth !== 0) {
return styles.cellNested;
}
return;
},
colSpan(args) {
return args.type === 'ROW' && args.row.__depth === 1 ? data.fields.length : 1;
},
renderCell: ({ row }) => {
if (row.__depth === 0) {
const rowIdx = row.__index;
const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => {
const fromFields = (f: Field[], widths: number[]) => {
const result: Schema = {
return (
<RowExpander
isExpanded={expandedRows.has(rowIdx)}
onCellExpand={() => {
if (expandedRows.has(rowIdx)) {
expandedRows.delete(rowIdx);
} else {
expandedRows.add(rowIdx);
}
setExpandedRows(new Set(expandedRows));
}}
/>
);
}
// Type guard to check if data exists as it's optional
const nestedData = row.data;
if (!nestedData) {
return null;
}
const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns);
if (!expandedRecords.length) {
return (
<div className={styles.noDataNested}>
<Trans i18nKey="grafana-ui.table.nested-table.no-data">No data</Trans>
</div>
);
}
return (
<DataGrid<TableRow, TableSummaryRow>
{...commonDataGridProps}
className={clsx(styles.grid, styles.gridNested)}
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0}
columns={nestedColumns}
rows={expandedRecords}
renderers={renderers}
/>
);
},
width: COLUMN.EXPANDER_WIDTH,
minWidth: COLUMN.EXPANDER_WIDTH,
}),
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles]
);
const fromFields = useCallback(
(f: Field[], widths: number[]): FromFieldsResult => {
const result: FromFieldsResult = {
columns: [],
cellRootRenderers: {},
colsWithTooltip: {},
@@ -334,11 +404,7 @@ export function TableNG(props: TableNGProps) {
// helps us avoid string cx and emotion per-cell
const cellActionClassName = showActions
? clsx(
'table-cell-actions',
styles.cellActions,
justifyContent === 'flex-end' ? styles.cellActionsEnd : styles.cellActionsStart
)
? clsx('table-cell-actions', getCellActionStyles(theme, textAlign))
: undefined;
const shouldOverflow = rowHeight !== 'auto' && shouldTextOverflow(field);
@@ -509,13 +575,13 @@ export function TableNG(props: TableNGProps) {
}
}
const column: TableColumn = {
result.columns.push({
field,
key: displayName,
name: displayName,
width,
headerCellClass,
frozen: Math.min(frozenColumns, numColsFullyInView) > i,
frozen: Math.min(frozenColumns, numFrozenColsFullyInView) > i,
renderCell: renderCellContent,
renderHeaderCell: ({ column, sortDirection }) => (
<HeaderCell
@@ -543,136 +609,78 @@ export function TableNG(props: TableNGProps) {
}
return <div className={footerStyles.footerCell}>{footerCalcs[i]}</div>;
},
};
result.columns.push(column);
});
});
return result;
};
},
[
applyToRowBgFn,
crossFilterOrder,
crossFilterRows,
data,
disableSanitizeHtml,
filter,
footerCalcs,
frozenColumns,
getCellActions,
isCountRowsSet,
numFrozenColsFullyInView,
onCellFilterAdded,
rowHeight,
rowHeightFn,
rows,
setFilter,
showTypeIcons,
theme,
timeRange,
]
);
// set up the first row's nested data and the nest field widths using useColWidths to avoid
// unnecessary re-renders on re-size.
const firstRowNestedData = useMemo(
() => (hasNestedFrames ? rows.find((r) => r.data)?.data : undefined),
[hasNestedFrames, rows]
);
const [nestedFieldWidths] = useColWidths(firstRowNestedData?.fields ?? [], availableWidth);
const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => {
const result = fromFields(visibleFields, widths);
// handle nested frames rendering from here.
if (!hasNestedFrames) {
// if nested frames are present, augment the columns to include the nested table expander column.
if (!firstRowNestedData) {
return result;
}
// pre-calculate renderRow and expandedColumns based on the first nested frame's fields.
const firstNestedData = rows.find((r) => r.data)?.data;
if (!firstNestedData) {
return result;
}
const hasNestedHeaders = firstNestedData.meta?.custom?.noHeader !== true;
const renderRow = renderRowFactory(firstNestedData.fields, panelContext, expandedRows, enableSharedCrosshair);
const hasNestedHeaders = firstRowNestedData.meta?.custom?.noHeader !== true;
const renderRow = renderRowFactory(firstRowNestedData.fields, panelContext, expandedRows, enableSharedCrosshair);
const { columns: nestedColumns, cellRootRenderers: nestedCellRootRenderers } = fromFields(
firstNestedData.fields,
computeColWidths(firstNestedData.fields, availableWidth)
firstRowNestedData.fields,
nestedFieldWidths
);
const renderCellRoot: CellRootRenderer = (key, props) => nestedCellRootRenderers[props.column.key](key, props);
result.cellRootRenderers.expanded = (key, props) => <Cell key={key} {...props} />;
const expanderCellRenderer: CellRootRenderer = (key, props) => <Cell key={key} {...props} />;
result.cellRootRenderers[EXPANDED_COLUMN_KEY] = expanderCellRenderer;
// If we have nested frames, we need to add a column for the row expansion
result.columns.unshift({
key: 'expanded',
name: '',
field: {
name: '',
type: FieldType.other,
config: {},
values: [],
},
cellClass(row) {
if (row.__depth !== 0) {
return styles.cellNested;
}
return;
},
colSpan(args) {
return args.type === 'ROW' && args.row.__depth === 1 ? data.fields.length : 1;
},
renderCell: ({ row }) => {
if (row.__depth === 0) {
const rowIdx = row.__index;
return (
<RowExpander
isExpanded={expandedRows.has(rowIdx)}
onCellExpand={() => {
if (expandedRows.has(rowIdx)) {
expandedRows.delete(rowIdx);
} else {
expandedRows.add(rowIdx);
}
setExpandedRows(new Set(expandedRows));
}}
/>
);
}
// Type guard to check if data exists as it's optional
const nestedData = row.data;
if (!nestedData) {
return null;
}
const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns);
if (!expandedRecords.length) {
return (
<div className={styles.noDataNested}>
<Trans i18nKey="grafana-ui.table.nested-table.no-data">No data</Trans>
</div>
);
}
return (
<DataGrid<TableRow, TableSummaryRow>
{...commonDataGridProps}
className={clsx(styles.grid, styles.gridNested)}
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0}
columns={nestedColumns}
rows={expandedRecords}
renderers={{ renderRow, renderCell: renderCellRoot }}
/>
);
},
width: COLUMN.EXPANDER_WIDTH,
minWidth: COLUMN.EXPANDER_WIDTH,
});
result.columns.unshift(
buildNestedTableExpanderColumn(nestedColumns, hasNestedHeaders, {
renderRow,
renderCell: (key, props) => nestedCellRootRenderers[props.column.key](key, props),
})
);
return result;
}, [
applyToRowBgFn,
availableWidth,
commonDataGridProps,
crossFilterOrder,
crossFilterRows,
data,
disableSanitizeHtml,
buildNestedTableExpanderColumn,
enableSharedCrosshair,
expandedRows,
filter,
footerCalcs,
frozenColumns,
getCellActions,
hasNestedFrames,
isCountRowsSet,
numColsFullyInView,
onCellFilterAdded,
firstRowNestedData,
fromFields,
nestedFieldWidths,
panelContext,
rowHeight,
rowHeightFn,
rows,
setFilter,
showTypeIcons,
sortColumns,
styles,
timeRange,
theme,
visibleFields,
widths,
]);
@@ -680,18 +688,16 @@ export function TableNG(props: TableNGProps) {
// invalidate columns on every structureRev change. this supports width editing in the fieldConfig.
// eslint-disable-next-line react-hooks/exhaustive-deps
const structureRevColumns = useMemo(() => columns, [columns, structureRev]);
const renderCellRoot: CellRootRenderer = useCallback(
(key, props) => cellRootRenderers[props.column.key](key, props),
[cellRootRenderers]
);
// we need to have variables with these exact names for the localization to work properly
const itemsRangeStart = pageRangeStart;
const displayedEnd = pageRangeEnd;
const numRows = sortedRows.length;
const renderCellRoot: CellRootRenderer = (key, props) => {
return cellRootRenderers[props.column.key](key, props);
};
const [tooltipState, setTooltipState] = useState<DataLinksActionsTooltipState>();
return (
<>
<DataGrid<TableRow, TableSummaryRow>
@@ -1,7 +1,14 @@
import { useState, useMemo, useCallback, useRef, useLayoutEffect, RefObject, CSSProperties } from 'react';
import { useState, useMemo, useCallback, useRef, useLayoutEffect, RefObject, CSSProperties, useEffect } from 'react';
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
import {
compareArrayValues,
Field,
fieldReducers,
FieldType,
formattedValueToString,
reduceField,
} from '@grafana/data';
import { TableColumnResizeActionCallback } from '../types';
@@ -13,6 +20,7 @@ import {
applySort,
getColumnTypes,
getRowHeight,
computeColWidths,
buildHeaderHeightMeasurers,
buildCellHeightMeasurers,
} from './utils';
@@ -183,16 +191,17 @@ export function usePaginatedRows(
return rows.slice(0, 100).reduce((avg, row, _, { length }) => avg + rowHeight(row) / length, 0);
}, [rows, rowHeight, enabled]);
const smallPagination = useMemo(() => enabled && width < TABLE.PAGINATION_LIMIT, [enabled, width]);
// using dimensions of the panel, calculate pagination parameters
const { numPages, rowsPerPage, pageRangeStart, pageRangeEnd, smallPagination } = useMemo((): {
const { numPages, rowsPerPage, pageRangeStart, pageRangeEnd } = useMemo((): {
numPages: number;
rowsPerPage: number;
pageRangeStart: number;
pageRangeEnd: number;
smallPagination: boolean;
} => {
if (!enabled) {
return { numPages: 0, rowsPerPage: 0, pageRangeStart: 1, pageRangeEnd: numRows, smallPagination: false };
return { numPages: 0, rowsPerPage: 0, pageRangeStart: 1, pageRangeEnd: numRows };
}
// calculate number of rowsPerPage based on height stack
@@ -207,16 +216,15 @@ export function usePaginatedRows(
if (pageRangeEnd > numRows) {
pageRangeEnd = numRows;
}
const smallPagination = width < TABLE.PAGINATION_LIMIT;
const numPages = Math.ceil(numRows / rowsPerPage);
return {
numPages,
rowsPerPage,
pageRangeStart,
pageRangeEnd,
smallPagination,
};
}, [width, height, headerHeight, footerHeight, avgRowHeight, enabled, numRows, page]);
}, [height, headerHeight, footerHeight, avgRowHeight, enabled, numRows, page]);
// safeguard against page overflow on panel resize or other factors
useLayoutEffect(() => {
@@ -542,3 +550,45 @@ export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number
return scrollbarWidth;
}
const numIsEqual = (a: number, b: number) => a === b;
export function useColWidths(
visibleFields: Field[],
availableWidth: number,
frozenColumns?: number
): [number[], number] {
const [widths, setWidths] = useState<number[]>(computeColWidths(visibleFields, availableWidth));
// only replace the widths array if something actually changed
useEffect(() => {
const newWidths = computeColWidths(visibleFields, availableWidth);
if (!compareArrayValues(widths, newWidths, numIsEqual)) {
setWidths(newWidths);
}
}, [availableWidth, widths, visibleFields]);
// this is to avoid buggy situations where all visible columns are frozen
const numFrozenColsFullyInView = useMemo(() => {
if (!frozenColumns || frozenColumns <= 0) {
return -1;
}
const fullyVisibleCols = widths.reduce(
([count, remainingWidth], nextWidth) => {
if (remainingWidth - nextWidth >= 0) {
return [count + 1, remainingWidth - nextWidth];
}
return [count, 0];
},
[0, availableWidth]
)[0];
// de-noise memoized changes to the columns array, and only change this
// number when the number of frozen columns changes or once there are fewer
// visible columns than the number of frozen columns.
return Math.min(fullyVisibleCols, frozenColumns);
}, [widths, availableWidth, frozenColumns]);
return [widths, numFrozenColsFullyInView];
}
@@ -88,19 +88,6 @@ export const getGridStyles = (theme: GrafanaTheme2, enablePagination?: boolean,
color: theme.colors.text.secondary,
fontSize: theme.typography.h4.fontSize,
}),
cellActions: css({
display: 'none',
position: 'absolute',
top: 0,
margin: 'auto',
height: '100%',
color: theme.colors.text.primary,
background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
padding: theme.spacing.x0_5,
paddingInlineStart: theme.spacing.x1,
}),
cellActionsEnd: css({ left: 0 }),
cellActionsStart: css({ right: 0 }),
headerRow: css({
paddingBlockStart: 0,
fontWeight: 'normal',
@@ -158,6 +145,20 @@ export const getDefaultCellStyles: TableCellStyles = (theme, { textAlign, should
},
});
export const getCellActionStyles = (theme: GrafanaTheme2, textAlign: TextAlign) =>
css({
display: 'none',
position: 'absolute',
top: 0,
margin: 'auto',
height: '100%',
color: theme.colors.text.primary,
background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
padding: theme.spacing.x0_5,
paddingInlineStart: theme.spacing.x1,
[textAlign === 'right' ? 'left' : 'right']: 0,
});
export const getLinkStyles = (theme: GrafanaTheme2, canBeColorized: boolean) =>
css({
a: {
@@ -1,5 +1,5 @@
import { FC, SyntheticEvent } from 'react';
import { Column } from 'react-data-grid';
import { CellRendererProps, Column } from 'react-data-grid';
import {
DataFrame,
@@ -306,3 +306,11 @@ export interface MeasureCellHeightEntry {
*/
fieldIdxs: number[];
}
export type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
export interface FromFieldsResult {
columns: TableColumn[];
cellRootRenderers: Record<string, CellRootRenderer>;
colsWithTooltip: Record<string, boolean>;
}
@@ -27,7 +27,19 @@ import { Options } from './panelcfg.gen';
interface Props extends PanelProps<Options> {}
export function TablePanel(props: Props) {
const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables, transparent } = props;
const {
data,
height,
width,
options,
onFieldConfigChange,
onOptionsChange,
fieldConfig,
id,
timeRange,
replaceVariables,
transparent,
} = props;
useMemo(() => {
cacheFieldDisplayNames(data.series);
@@ -47,6 +59,59 @@ export function TablePanel(props: Props) {
const currentIndex = getCurrentFrameIndex(frames, options);
const main = frames[currentIndex];
const onColumnResize = useCallback(
(fieldDisplayName: string, width: number) => {
const { overrides } = fieldConfig;
const matcherId = FieldMatcherID.byName;
const propId = 'custom.width';
// look for existing override
const override = overrides.find((o) => o.matcher.id === matcherId && o.matcher.options === fieldDisplayName);
if (override) {
// look for existing property
const property = override.properties.find((prop) => prop.id === propId);
if (property) {
property.value = width;
} else {
override.properties.push({ id: propId, value: width });
}
} else {
overrides.push({
matcher: { id: matcherId, options: fieldDisplayName },
properties: [{ id: propId, value: width }],
});
}
onFieldConfigChange({
...fieldConfig,
overrides,
});
},
[fieldConfig, onFieldConfigChange]
);
const onSortByChange = useCallback(
(sortBy: TableSortByFieldState[]) => {
onOptionsChange({
...options,
sortBy,
});
},
[options, onOptionsChange]
);
const onChangeTableSelection = useCallback(
(val: SelectableValue<number>) => {
onOptionsChange({
...options,
frameIndex: val.value || 0,
});
},
[options, onOptionsChange]
);
let tableHeight = height;
if (!count || !hasFields) {
@@ -73,8 +138,8 @@ export function TablePanel(props: Props) {
showTypeIcons={options.showTypeIcons}
resizable={true}
initialSortBy={options.sortBy}
onSortByChange={(sortBy) => onSortByChange(sortBy, props)}
onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)}
onSortByChange={onSortByChange}
onColumnResize={onColumnResize}
onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer}
frozenColumns={options.frozenColumns?.left}
@@ -105,7 +170,7 @@ export function TablePanel(props: Props) {
<div className={tableStyles.wrapper}>
{tableElement}
<div className={tableStyles.selectWrapper}>
<Select options={names} value={names[currentIndex]} onChange={(val) => onChangeTableSelection(val, props)} />
<Select options={names} value={names[currentIndex]} onChange={onChangeTableSelection} />
</div>
</div>
);
@@ -115,51 +180,6 @@ function getCurrentFrameIndex(frames: DataFrame[], options: Options) {
return options.frameIndex > 0 && options.frameIndex < frames.length ? options.frameIndex : 0;
}
function onColumnResize(fieldDisplayName: string, width: number, props: Props) {
const { fieldConfig } = props;
const { overrides } = fieldConfig;
const matcherId = FieldMatcherID.byName;
const propId = 'custom.width';
// look for existing override
const override = overrides.find((o) => o.matcher.id === matcherId && o.matcher.options === fieldDisplayName);
if (override) {
// look for existing property
const property = override.properties.find((prop) => prop.id === propId);
if (property) {
property.value = width;
} else {
override.properties.push({ id: propId, value: width });
}
} else {
overrides.push({
matcher: { id: matcherId, options: fieldDisplayName },
properties: [{ id: propId, value: width }],
});
}
props.onFieldConfigChange({
...fieldConfig,
overrides,
});
}
function onSortByChange(sortBy: TableSortByFieldState[], props: Props) {
props.onOptionsChange({
...props.options,
sortBy,
});
}
function onChangeTableSelection(val: SelectableValue<number>, props: Props) {
props.onOptionsChange({
...props.options,
frameIndex: val.value || 0,
});
}
// placeholder function; assuming the values are already interpolated
const replaceVars: InterpolateFunction = (value: string) => value;