From e91c12db64fe39a991eaa0e6f2dc6a3b826d81b6 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 21 Aug 2025 15:22:30 -0400 Subject: [PATCH] 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 --- .../src/components/Table/TableNG/TableNG.tsx | 300 +++++++++--------- .../src/components/Table/TableNG/hooks.ts | 66 +++- .../src/components/Table/TableNG/styles.ts | 27 +- .../src/components/Table/TableNG/types.ts | 10 +- .../panel/table/table-new/TablePanel.tsx | 118 ++++--- 5 files changed, 303 insertions(+), 218 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index e53a1d9e827..bb3c3fea8fa 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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) => 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(null); + const [tooltipState, setTooltipState] = useState(); const [expandedRows, setExpandedRows] = useState(() => new Set()); // 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; - colsWithTooltip: Record; - } + const buildNestedTableExpanderColumn = useCallback( + ( + nestedColumns: TableColumn[], + hasNestedHeaders: boolean, + renderers: Renderers + ): 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 ( + { + 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 ( +
+ No data +
+ ); + } + + return ( + + {...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 }) => ( {footerCalcs[i]}; }, - }; - - 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) => ; + const expanderCellRenderer: CellRootRenderer = (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 ( - { - 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 ( -
- No data -
- ); - } - - return ( - - {...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(); - return ( <> diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index dbfca40776d..6cd4d836355 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -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, 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(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]; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/styles.ts b/packages/grafana-ui/src/components/Table/TableNG/styles.ts index b21bb702041..1094108a587 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/styles.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/styles.ts @@ -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: { diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 0f1883456a7..efdd9495336 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -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) => React.ReactNode; + +export interface FromFieldsResult { + columns: TableColumn[]; + cellRootRenderers: Record; + colsWithTooltip: Record; +} diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx index 18bce856a4a..d656041bd33 100644 --- a/public/app/plugins/panel/table/table-new/TablePanel.tsx +++ b/public/app/plugins/panel/table/table-new/TablePanel.tsx @@ -27,7 +27,19 @@ import { Options } from './panelcfg.gen'; interface Props extends PanelProps {} 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) => { + 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) {
{tableElement}
-
); @@ -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, props: Props) { - props.onOptionsChange({ - ...props.options, - frameIndex: val.value || 0, - }); -} - // placeholder function; assuming the values are already interpolated const replaceVars: InterpolateFunction = (value: string) => value;