diff --git a/.betterer.results b/.betterer.results index 82da11f2cc6..cc04defd879 100644 --- a/.betterer.results +++ b/.betterer.results @@ -702,9 +702,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -720,15 +718,11 @@ exports[`better eslint`] = { ], "packages/grafana-ui/src/components/Table/TableNG/utils.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-ui/src/components/Table/TableNG/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "packages/grafana-ui/src/components/Table/TableRT/Filter.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts index 2737732a383..a778302ba26 100644 --- a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts @@ -46,7 +46,7 @@ describe('GroupToSubframe transformer', () => { values: ['one', 'two', 'three'], }, { - name: 'Nested frames', + name: '__nestedFrames', type: FieldType.nestedFrames, config: {}, values: [ @@ -153,7 +153,7 @@ describe('GroupToSubframe transformer', () => { }, { config: {}, - name: 'Nested frames', + name: '__nestedFrames', type: FieldType.nestedFrames, values: [ [ diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts index 3f6843afeb1..943c3d842c0 100644 --- a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts @@ -124,7 +124,7 @@ export const groupToNestedTable: DataTransformerInfo; rows: TableRow[]; field: Field; - onSort: (columnKey: string, direction: SortDirection, isMultiSort: boolean) => void; direction?: SortDirection; - justifyContent: Property.JustifyContent; filter: FilterType; setFilter: React.Dispatch>; - onColumnResize?: TableColumnResizeActionCallback; - headerCellRefs: React.MutableRefObject>; - crossFilterOrder: React.MutableRefObject; - crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>; + crossFilterOrder: string[]; + crossFilterRows: { [key: string]: TableRow[] }; showTypeIcons?: boolean; } @@ -32,113 +27,54 @@ const HeaderCell: React.FC = ({ column, rows, field, - onSort, direction, - justifyContent, filter, setFilter, - onColumnResize, - headerCellRefs, crossFilterOrder, crossFilterRows, showTypeIcons, }) => { - const styles = useStyles2(getStyles, justifyContent); - const headerRef = useRef(null); + const styles = useStyles2(getStyles); + const displayName = useMemo(() => getDisplayName(field), [field]); + const filterable = useMemo(() => field.config.custom?.filterable ?? false, [field]); - const filterable = field.config?.custom?.filterable ?? false; - const displayName = getDisplayName(field); - - let isColumnFilterable = filterable; - if (field.config.custom?.filterable !== filterable) { - isColumnFilterable = field.config.custom?.filterable || false; - } // we have to remove/reset the filter if the column is not filterable - if (!isColumnFilterable && filter[displayName]) { - setFilter((filter: FilterType) => { - const newFilter = { ...filter }; - delete newFilter[displayName]; - return newFilter; - }); - } - - const handleSort = (event: React.MouseEvent) => { - const isMultiSort = event.shiftKey; - onSort(column.key, direction === 'ASC' ? 'DESC' : 'ASC', isMultiSort); - }; - - // collecting header cell refs to handle manual column resize - useLayoutEffect(() => { - if (headerRef.current) { - headerCellRefs.current[column.key] = headerRef.current; - } - }, [headerRef, column.key]); // eslint-disable-line react-hooks/exhaustive-deps - - // TODO: this is a workaround to handle manual column resize; useEffect(() => { - const headerCellParent = headerRef.current?.parentElement; - if (headerCellParent) { - // `lastElement` is an HTML element added by react-data-grid for resizing columns. - // We add event listeners to `lastElement` to handle the resize operation. - const lastElement = headerCellParent.lastElementChild; - if (lastElement) { - const handleMouseUp = () => { - let newWidth = headerCellParent.clientWidth; - onColumnResize?.(column.key, newWidth); - }; - - lastElement.addEventListener('click', handleMouseUp); - - return () => { - lastElement.removeEventListener('click', handleMouseUp); - }; - } + if (!filterable && filter[displayName]) { + setFilter((filter: FilterType) => { + const newFilter = { ...filter }; + delete newFilter[displayName]; + return newFilter; + }); } - // to handle "Not all code paths return a value." error - return; - }, [column]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filterable, displayName, filter, setFilter]); return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
{ - if (event.key === ' ') { - event.stopPropagation(); - } - }} - > - + - {isColumnFilterable && ( + {filterable && ( )} -
+ ); }; -const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ - headerCell: css({ - display: 'flex', - gap: theme.spacing(0.5), - justifyContent, - }), +const getStyles = (theme: GrafanaTheme2) => ({ headerCellLabel: css({ border: 'none', padding: 0, diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index 13c4727e4e7..348d0aa168c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -3,10 +3,10 @@ import { Property } from 'csstype'; import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { TableCellDisplayMode } from '@grafana/schema'; import { useStyles2 } from '../../../../themes/ThemeContext'; import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { TableCellDisplayMode } from '../../types'; import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { ImageCellProps } from '../types'; import { getCellLinks } from '../utils'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx index 95e29ce17f3..019afb7e243 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx @@ -12,7 +12,7 @@ export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGP function handleKeyDown(e: React.KeyboardEvent) { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); - onCellExpand(); + onCellExpand(e); } } return ( diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx index 190de5b85bd..225790cb0bb 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx @@ -82,7 +82,7 @@ export const SparklineCell = (props: SparklineCellProps) => { }, }; - const hideValue = field.config.custom?.cellOptions?.hideValue; + const hideValue = cellOptions.hideValue; let valueWidth = 0; let valueElement: React.ReactNode = null; if (!hideValue) { diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx new file mode 100644 index 00000000000..eb67a369cb2 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx @@ -0,0 +1,82 @@ +import { WKT } from 'ol/format'; +import { Geometry } from 'ol/geom'; + +import { FieldType } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { IconButton } from '../../../IconButton/IconButton'; +import { TableCellInspectorMode } from '../../TableCellInspector'; +import { TableCellDisplayMode } from '../../types'; +import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableCellActionsProps } from '../types'; + +export function TableCellActions(props: TableCellActionsProps) { + const { + field, + value, + cellOptions, + displayName, + setIsInspecting, + setContextMenuProps, + onCellFilterAdded, + className, + cellInspect, + showFilters, + } = props; + + return ( +
+ {cellInspect && ( + { + let inspectValue = value; + let mode = TableCellInspectorMode.text; + + if (field.type === FieldType.geo && value instanceof Geometry) { + inspectValue = new WKT().writeGeometry(value, { + featureProjection: 'EPSG:3857', + dataProjection: 'EPSG:4326', + }); + mode = TableCellInspectorMode.code; + } else if ('cellType' in cellOptions && cellOptions.cellType === TableCellDisplayMode.JSONView) { + mode = TableCellInspectorMode.code; + } + + setContextMenuProps({ + value: String(inspectValue ?? ''), + mode, + }); + setIsInspecting(true); + }} + /> + )} + {showFilters && ( + <> + { + onCellFilterAdded?.({ + key: displayName, + operator: FILTER_FOR_OPERATOR, + value: String(value ?? ''), + }); + }} + /> + { + onCellFilterAdded?.({ + key: displayName, + operator: FILTER_OUT_OPERATOR, + value: String(value ?? ''), + }); + }} + /> + + )} +
+ ); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx deleted file mode 100644 index a3a57a5564c..00000000000 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { css } from '@emotion/css'; -import { WKT } from 'ol/format'; -import { Geometry } from 'ol/geom'; -import { ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import { FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { TableAutoCellOptions, TableCellDisplayMode } from '@grafana/schema'; - -import { useStyles2 } from '../../../../themes/ThemeContext'; -import { IconButton } from '../../../IconButton/IconButton'; -// import { GeoCell } from '../../Cells/GeoCell'; -import { TableCellInspectorMode } from '../../TableCellInspector'; -import { - CellColors, - CustomCellRendererProps, - FILTER_FOR_OPERATOR, - FILTER_OUT_OPERATOR, - TableCellNGProps, -} from '../types'; -import { getCellColors, getDisplayName, getTextAlign } from '../utils'; - -import { ActionsCell } from './ActionsCell'; -import AutoCell from './AutoCell'; -import { BarGaugeCell } from './BarGaugeCell'; -import { DataLinksCell } from './DataLinksCell'; -import { GeoCell } from './GeoCell'; -import { ImageCell } from './ImageCell'; -import { JSONCell } from './JSONCell'; -import { SparklineCell } from './SparklineCell'; - -export function TableCellNG(props: TableCellNGProps) { - const { - field, - frame, - value, - theme, - timeRange, - height, - rowIdx, - justifyContent, - shouldTextOverflow, - setIsInspecting, - setContextMenuProps, - getActions, - rowBg, - onCellFilterAdded, - replaceVariables, - } = props; - - const cellInspect = field.config?.custom?.inspect ?? false; - const displayName = getDisplayName(field); - - const { config: fieldConfig } = field; - const defaultCellOptions: TableAutoCellOptions = { type: TableCellDisplayMode.Auto }; - const cellOptions = fieldConfig.custom?.cellOptions ?? defaultCellOptions; - const { type: cellType } = cellOptions; - - const showFilters = field.config.filterable && onCellFilterAdded; - - const isRightAligned = getTextAlign(field) === 'flex-end'; - const displayValue = field.display!(value); - let colors: CellColors = { bgColor: '', textColor: '', bgHoverColor: '' }; - if (rowBg) { - colors = rowBg(rowIdx); - } else { - colors = useMemo(() => getCellColors(theme, cellOptions, displayValue), [theme, cellOptions, displayValue]); - } - const styles = useStyles2(getStyles, isRightAligned, colors); - - // TODO - // TableNG provides either an overridden cell width or 'auto' as the cell width value. - // While the overridden value gives the exact cell width, 'auto' does not. - // Therefore, we need to determine the actual cell width from the DOM. - const divWidthRef = useRef(null); - const [divWidth, setDivWidth] = useState(0); - const [isHovered, setIsHovered] = useState(false); - - const actions = useMemo( - () => (getActions ? getActions(frame, field, rowIdx, replaceVariables) : []), - [getActions, frame, field, rowIdx, replaceVariables] - ); - - useLayoutEffect(() => { - if (divWidthRef.current && divWidthRef.current.clientWidth !== 0) { - setDivWidth(divWidthRef.current.clientWidth); - } - }, [divWidthRef.current]); // eslint-disable-line react-hooks/exhaustive-deps - - // Common props for all cells - const commonProps = useMemo( - () => ({ - value, - field, - rowIdx, - justifyContent, - }), - [value, field, rowIdx, justifyContent] - ); - - // Get the correct cell type - const renderedCell = useMemo(() => { - let cell: ReactNode = null; - switch (cellType) { - case TableCellDisplayMode.Sparkline: - cell = ; - break; - case TableCellDisplayMode.Gauge: - case TableCellDisplayMode.BasicGauge: - case TableCellDisplayMode.GradientGauge: - case TableCellDisplayMode.LcdGauge: - cell = ( - - ); - break; - case TableCellDisplayMode.Image: - cell = ; - break; - case TableCellDisplayMode.JSONView: - cell = ; - break; - case TableCellDisplayMode.DataLinks: - cell = ; - break; - case TableCellDisplayMode.Actions: - cell = ; - break; - case TableCellDisplayMode.Custom: - const CustomCellComponent: React.ComponentType = cellOptions.cellComponent; - cell = ; - break; - case TableCellDisplayMode.Auto: - default: - // Handle auto cell type detection - if (field.type === FieldType.geo) { - cell = ; - } else if (field.type === FieldType.frame) { - const firstValue = field.values[0]; - if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { - cell = ; - } else { - cell = ; - } - } else if (field.type === FieldType.other) { - cell = ; - } else { - cell = ; - } - break; - } - return cell; - }, [cellType, commonProps, theme, timeRange, divWidth, height, cellOptions, field, rowIdx, actions, value, frame]); - - const handleMouseEnter = () => { - setIsHovered(true); - if (shouldTextOverflow()) { - // TODO: The table cell styles in TableNG do not update dynamically even if we change the state - const div = divWidthRef.current; - const tableCellDiv = div?.parentElement; - tableCellDiv?.style.setProperty('z-index', String(theme.zIndex.tooltip)); - tableCellDiv?.style.setProperty('white-space', 'pre-line'); - tableCellDiv?.style.setProperty('min-height', `100%`); - tableCellDiv?.style.setProperty('height', `fit-content`); - tableCellDiv?.style.setProperty('background', colors.bgHoverColor || 'none'); - tableCellDiv?.style.setProperty('min-width', 'min-content'); - } - }; - - const handleMouseLeave = () => { - setIsHovered(false); - if (shouldTextOverflow()) { - // TODO: The table cell styles in TableNG do not update dynamically even if we change the state - const div = divWidthRef.current; - const tableCellDiv = div?.parentElement; - tableCellDiv?.style.removeProperty('z-index'); - tableCellDiv?.style.removeProperty('white-space'); - tableCellDiv?.style.removeProperty('min-height'); - tableCellDiv?.style.removeProperty('height'); - tableCellDiv?.style.removeProperty('background'); - tableCellDiv?.style.removeProperty('min-width'); - } - }; - - const onFilterFor = useCallback(() => { - if (onCellFilterAdded) { - onCellFilterAdded({ - key: displayName, - operator: FILTER_FOR_OPERATOR, - value: String(value ?? ''), - }); - } - }, [displayName, onCellFilterAdded, value]); - - const onFilterOut = useCallback(() => { - if (onCellFilterAdded) { - onCellFilterAdded({ - key: displayName, - operator: FILTER_OUT_OPERATOR, - value: String(value ?? ''), - }); - } - }, [displayName, onCellFilterAdded, value]); - - return ( -
- {renderedCell} - {isHovered && (cellInspect || showFilters) && ( -
- {cellInspect && ( - { - let inspectValue = value; - let mode = TableCellInspectorMode.text; - - if (field.type === FieldType.geo && value instanceof Geometry) { - inspectValue = new WKT().writeGeometry(value, { - featureProjection: 'EPSG:3857', - dataProjection: 'EPSG:4326', - }); - mode = TableCellInspectorMode.code; - } else if (cellType === TableCellDisplayMode.JSONView) { - mode = TableCellInspectorMode.code; - } - - setContextMenuProps({ - value: String(inspectValue ?? ''), - mode, - }); - setIsInspecting(true); - }} - /> - )} - {showFilters && ( - <> - - - - )} -
- )} -
- ); -} - -const getStyles = (theme: GrafanaTheme2, isRightAligned: boolean, color: CellColors) => ({ - cell: css({ - height: '100%', - alignContent: 'center', - paddingInline: '8px', - // TODO: follow-up on this: change styles on hover on table row level - background: color.bgColor || 'none', - color: color.textColor, - '&:hover': { background: color.bgHoverColor }, - }), - cellActions: css({ - display: 'flex', - position: 'absolute', - top: '1px', - left: isRightAligned ? 0 : undefined, - right: isRightAligned ? undefined : 0, - margin: 'auto', - height: '100%', - background: theme.colors.background.secondary, - color: theme.colors.text.primary, - padding: '4px 0px 4px 4px', - }), -}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx new file mode 100644 index 00000000000..b27c050445d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx @@ -0,0 +1,343 @@ +import { render } from '@testing-library/react'; + +import { createDataFrame, createTheme, Field, FieldType } from '@grafana/data'; + +import { TableCellOptions, TableCellDisplayMode, TableCustomCellOptions } from '../../types'; + +import { getCellRenderer } from './renderers'; + +// Performance testing utilities +const measurePerformance = (fn: () => void, iterations = 100) => { + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + fn(); + } + const end = performance.now(); + return (end - start) / iterations; // Average time per iteration +}; + +const createLargeTimeSeriesFrame = () => { + const timeValues = Array.from({ length: 100 }, (_, i) => Date.now() + i * 1000); + const valueValues = Array.from({ length: 100 }, (_, i) => Math.random() * 100); + + return createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: timeValues }, + { name: 'value', type: FieldType.number, values: valueValues }, + ], + }); +}; + +const createLargeJSONData = () => { + return { + id: 1, + name: 'Test Object', + metadata: { + tags: Array.from({ length: 50 }, (_, i) => `tag-${i}`), + properties: Array.from({ length: 100 }, (_, i) => ({ key: `prop-${i}`, value: `value-${i}` })), + nested: { + level1: { + level2: { + level3: { + data: Array.from({ length: 20 }, (_, i) => ({ id: i, value: Math.random() })), + }, + }, + }, + }, + }, + array: Array.from({ length: 200 }, (_, i) => ({ id: i, value: Math.random() * 1000 })), + }; +}; + +describe('TableNG Cells renderers', () => { + describe('getCellRenderer', () => { + // Helper function to create a basic field + function createField(type: FieldType, values: V[] = []): Field { + return { + name: 'test', + type, + values, + config: {}, + state: {}, + display: jest.fn(() => ({ text: 'black', color: 'white', numeric: 0 })), + }; + } + + // Helper function to render a cell and get the test ID + const renderCell = (field: Field, cellOptions: TableCellOptions) => + render( + getCellRenderer( + field, + cellOptions + )({ + field, + value: 'test-value', + rowIdx: 0, + frame: createDataFrame({ fields: [field] }), + height: 100, + width: 100, + theme: createTheme(), + cellOptions, + cellInspect: false, + showFilters: false, + justifyContent: 'flex-start', + }) + ); + + // Performance test helper + const benchmarkCellPerformance = (field: Field, cellOptions: TableCellOptions, iterations = 100) => { + // eslint-disable-next-line testing-library/render-result-naming-convention + const r = getCellRenderer(field, cellOptions); + return measurePerformance(() => { + render( + r({ + field, + value: 'test-value', + rowIdx: 0, + frame: createDataFrame({ fields: [field] }), + height: 100, + width: 100, + theme: createTheme(), + cellOptions, + cellInspect: false, + showFilters: false, + justifyContent: 'flex-start', + }) + ); + }, iterations); + }; + + describe('explicit cell type cases', () => { + it.each([ + { type: TableCellDisplayMode.Sparkline, fieldType: FieldType.number }, + { type: TableCellDisplayMode.Gauge, fieldType: FieldType.number }, + { type: TableCellDisplayMode.JSONView, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Image, fieldType: FieldType.string }, + { type: TableCellDisplayMode.DataLinks, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Actions, fieldType: FieldType.string }, + { type: TableCellDisplayMode.ColorText, fieldType: FieldType.string }, + { type: TableCellDisplayMode.ColorBackground, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Auto, fieldType: FieldType.string }, + ] as const)('should render $type cell into the document', ({ type, fieldType }) => { + const field = createField(fieldType); + const { container } = renderCell(field, { type }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + describe('invalid config cases', () => { + it('should return AutoCell when cellOptions.type is undefined', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, { type: undefined } as unknown as TableCellOptions); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell when cellOptions is undefined', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, undefined as unknown as TableCellOptions); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + }); + }); + + describe('auto mode field type cases', () => { + it('should return GeoCell for geo field type', () => { + const field = createField(FieldType.geo); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return SparklineCell for frame field type with time series', () => { + const timeSeriesFrame = createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const field = createField(FieldType.frame, [timeSeriesFrame]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return JSONCell for frame field type with non-time series', () => { + const regularFrame = createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const field = createField(FieldType.frame, [regularFrame]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return JSONCell for other field type', () => { + const field = createField(FieldType.other); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for string field type', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for number field type', () => { + const field = createField(FieldType.number); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for boolean field type', () => { + const field = createField(FieldType.boolean); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for time field type', () => { + const field = createField(FieldType.time); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + }); + + describe('custom cell renderer cases', () => { + it('should return custom cell component for Custom type with valid cellComponent', () => { + const CustomComponent = () =>
CustomCell
; + const field = createField(FieldType.string); + + const { container } = renderCell(field, { + type: TableCellDisplayMode.Custom, + cellComponent: CustomComponent, + }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('(invalid) should return null for Custom type without cellComponent', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, { + type: TableCellDisplayMode.Custom, + cellComponent: undefined, + } as unknown as TableCustomCellOptions); + + expect(container.childNodes).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('should handle empty field values array', () => { + const field = createField(FieldType.frame, []); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should handle field with null values', () => { + const field = createField(FieldType.frame, [null]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should handle field with undefined values', () => { + const field = createField(FieldType.frame, [undefined]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + }); + + describe.skip('performance benchmarks', () => { + // Performance thresholds (in milliseconds) + // these thresholds are tweaked based on performance on CI, not on a typical dev machine. + const PERFORMANCE_THRESHOLDS = { + FAST: 1, // Should render in under 1ms + MEDIUM: 2.5, // Should render in under 2.5ms + SLOW: 5, // Should render in under 5ms + }; + + describe('explicit cell type performance', () => { + it.each([ + { type: TableCellDisplayMode.Sparkline, threshold: PERFORMANCE_THRESHOLDS.MEDIUM }, + { type: TableCellDisplayMode.Gauge, threshold: PERFORMANCE_THRESHOLDS.SLOW }, + { type: TableCellDisplayMode.JSONView, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.Image, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.DataLinks, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.Actions, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.ColorText, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.ColorBackground, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.Auto, threshold: PERFORMANCE_THRESHOLDS.FAST }, + ] as const)('should render $type within performance threshold', ({ type, threshold }) => { + const field = createField(FieldType.number); + const avgTime = benchmarkCellPerformance(field, { type }, 100); + expect(avgTime).toBeLessThan(threshold); + }); + }); + + describe('custom cell renderer performance', () => { + it('should render custom cell component within performance threshold', () => { + const CustomComponent = () =>
CustomCell
; + const field = createField(FieldType.string); + const avgTime = benchmarkCellPerformance( + field, + { + type: TableCellDisplayMode.Custom, + cellComponent: CustomComponent, + }, + 50 + ); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.FAST); + }); + }); + + describe('large data performance', () => { + it('should render JSONCell with large JSON data within performance threshold', () => { + const largeJSON = createLargeJSONData(); + const field = createField(FieldType.string, [JSON.stringify(largeJSON)]); + const avgTime = benchmarkCellPerformance(field, { type: TableCellDisplayMode.JSONView }, 20); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.SLOW); + }); + + it('should render SparklineCell with large time series within performance threshold', () => { + const largeTimeSeriesFrame = createLargeTimeSeriesFrame(); + const field = createField(FieldType.frame, [largeTimeSeriesFrame]); + const avgTime = benchmarkCellPerformance(field, { type: TableCellDisplayMode.Sparkline }, 10); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.MEDIUM); + }); + + it('should render AutoCell with large string data within performance threshold', () => { + const largeString = 'x'.repeat(10000); // 10KB string + const field = createField(FieldType.string, [largeString]); + const avgTime = benchmarkCellPerformance(field, { type: TableCellDisplayMode.Auto }, 30); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.MEDIUM); + }); + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx new file mode 100644 index 00000000000..7bb4b39f2cb --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -0,0 +1,135 @@ +import { ReactNode } from 'react'; + +import { Field, FieldType, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; + +import { TableCellDisplayMode, TableCellOptions, TableCustomCellOptions } from '../../types'; +import { TableCellRendererProps } from '../types'; + +import { ActionsCell } from './ActionsCell'; +import AutoCell from './AutoCell'; +import { BarGaugeCell } from './BarGaugeCell'; +import { DataLinksCell } from './DataLinksCell'; +import { GeoCell } from './GeoCell'; +import { ImageCell } from './ImageCell'; +import { JSONCell } from './JSONCell'; +import { SparklineCell } from './SparklineCell'; + +export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode; + +const GAUGE_RENDERER: TableCellRenderer = (props) => ( + +); + +const AUTO_RENDERER: TableCellRenderer = (props) => ( + +); + +const SPARKLINE_RENDERER: TableCellRenderer = (props) => ( + +); + +const JSON_RENDERER: TableCellRenderer = (props) => ( + +); + +const GEO_RENDERER: TableCellRenderer = (props) => ( + +); + +const IMAGE_RENDERER: TableCellRenderer = (props) => ( + +); + +const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; + +const ACTIONS_RENDERER: TableCellRenderer = (props) => ; + +function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions { + return options.type === TableCellDisplayMode.Custom; +} + +const CUSTOM_RENDERER: TableCellRenderer = (props) => { + if (!isCustomCellOptions(props.cellOptions) || !props.cellOptions.cellComponent) { + return null; // nonsensical case, but better to typeguard it than throw. + } + const CustomCellComponent = props.cellOptions.cellComponent; + return ; +}; + +const CELL_RENDERERS: Record = { + [TableCellDisplayMode.Sparkline]: SPARKLINE_RENDERER, + [TableCellDisplayMode.Gauge]: GAUGE_RENDERER, + [TableCellDisplayMode.JSONView]: JSON_RENDERER, + [TableCellDisplayMode.Image]: IMAGE_RENDERER, + [TableCellDisplayMode.DataLinks]: DATA_LINKS_RENDERER, + [TableCellDisplayMode.Actions]: ACTIONS_RENDERER, + [TableCellDisplayMode.Custom]: CUSTOM_RENDERER, + [TableCellDisplayMode.ColorText]: AUTO_RENDERER, + [TableCellDisplayMode.ColorBackground]: AUTO_RENDERER, + [TableCellDisplayMode.Auto]: AUTO_RENDERER, +}; + +/** @internal */ +export function getCellRenderer(field: Field, cellOptions: TableCellOptions): TableCellRenderer { + const cellType = cellOptions?.type ?? TableCellDisplayMode.Auto; + if (cellType === TableCellDisplayMode.Auto) { + return getAutoRendererResult(field); + } + return CELL_RENDERERS[cellType]; +} + +/** @internal */ +export function getAutoRendererResult(field: Field): TableCellRenderer { + if (field.type === FieldType.geo) { + return GEO_RENDERER; + } + if (field.type === FieldType.frame) { + const firstValue = field.values[0]; + if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { + return SPARKLINE_RENDERER; + } else { + return JSON_RENDERER; + } + } + if (field.type === FieldType.other) { + return JSON_RENDERER; + } + return AUTO_RENDERER; +} 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 120cbc985a7..acde579e401 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx @@ -1,12 +1,12 @@ import { css, cx } from '@emotion/css'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { useStyles2 } from '../../../../themes/ThemeContext'; import { Icon } from '../../../Icon/Icon'; import { Popover } from '../../../Tooltip/Popover'; -import { TableRow } from '../types'; +import { FilterType, TableRow } from '../types'; import { REGEX_OPERATOR } from './FilterList'; import { FilterPopup } from './FilterPopup'; @@ -14,8 +14,8 @@ import { FilterPopup } from './FilterPopup'; interface Props { name: string; rows: any[]; - filter: any; - setFilter: (value: any) => void; + filter: FilterType; + setFilter: (value: FilterType) => void; field?: Field; crossFilterOrder: string[]; crossFilterRows: { [key: string]: TableRow[] }; @@ -43,8 +43,6 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, const [isPopoverVisible, setPopoverVisible] = useState(false); const styles = useStyles2(getStyles); const filterEnabled = useMemo(() => Boolean(filterValue), [filterValue]); - const onShowPopover = useCallback(() => setPopoverVisible(true), [setPopoverVisible]); - const onClosePopover = useCallback(() => setPopoverVisible(false), [setPopoverVisible]); const [searchFilter, setSearchFilter] = useState(filter[name]?.searchFilter || ''); const [operator, setOperator] = useState>(filter[name]?.operator || REGEX_OPERATOR); @@ -53,7 +51,10 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, className={cx(styles.headerFilter, filterEnabled ? styles.filterIconEnabled : styles.filterIconDisabled)} ref={ref} type="button" - onClick={onShowPopover} + onClick={(ev) => { + setPopoverVisible(true); + ev.stopPropagation(); + }} > {isPopoverVisible && ref.current && ( @@ -65,13 +66,18 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, filterValue={filterValue} setFilter={setFilter} field={field} - onClose={onClosePopover} + onClose={() => setPopoverVisible(false)} searchFilter={searchFilter} setSearchFilter={setSearchFilter} operator={operator} setOperator={setOperator} /> } + onKeyDown={(event) => { + if (event.key === ' ') { + event.stopPropagation(); + } + }} placement="bottom-start" referenceElement={ref.current} show diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx index d11d66cf2e0..9893504f297 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx @@ -12,6 +12,7 @@ import { FilterInput } from '../../../FilterInput/FilterInput'; import { Label } from '../../../Forms/Label'; import { Stack } from '../../../Layout/Stack/Stack'; import { FilterType } from '../types'; +import { getDisplayName } from '../utils'; import { FilterList } from './FilterList'; import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from './utils'; @@ -110,7 +111,7 @@ export const FilterPopup = ({
- {field && } + {field && } { @@ -141,7 +142,7 @@ const createNestedDataFrame = (): DataFrame => { config: { custom: { hidden: true } }, }, { - name: 'Nested frames', + name: '__nestedFrames', type: FieldType.nestedFrames, values: [[processedNestedFrame], [processedNestedFrame]], config: { custom: {} }, @@ -251,7 +252,6 @@ const createSortingTestDataFrame = (): DataFrame => { })[0]; }; -// Create a data frame with time field for testing crosshair sharing functionality const createTimeDataFrame = (): DataFrame => { const frame = toDataFrame({ name: 'TimeTestData', @@ -324,7 +324,7 @@ describe('TableNG', () => { }); describe('Basic TableNG rendering', () => { - it('renders a simple table with columns and rows', () => { + it('renders a simple table with columns and rows', async () => { const { container } = render( ); @@ -649,9 +649,6 @@ describe('TableNG', () => { describe('Sorting', () => { it('allows sorting when clicking on column headers', async () => { - // Mock scrollIntoView - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - const { container } = render( ); @@ -661,50 +658,49 @@ describe('TableNG', () => { expect(columnHeader).toBeInTheDocument(); // Find the sort button within the first header - if (columnHeader) { - // Store the initial state of the header - const initialSortAttribute = columnHeader.getAttribute('aria-sort'); + if (!columnHeader) { + throw new Error('No column header found'); + } - // Look for a button inside the header - const sortButton = columnHeader.querySelector('button') || columnHeader; + // Store the initial state of the header + const initialSortAttribute = columnHeader.getAttribute('aria-sort'); - // Click the sort button - await user.click(sortButton); + // Look for a button inside the header + const sortButton = columnHeader.querySelector('button') || columnHeader; - // After clicking, the header should have an aria-sort attribute - const newSortAttribute = columnHeader.getAttribute('aria-sort'); + // Click the sort button + await user.click(sortButton); - // The sort attribute should have changed - expect(newSortAttribute).not.toBe(initialSortAttribute); + // After clicking, the header should have an aria-sort attribute + const newSortAttribute = columnHeader.getAttribute('aria-sort'); - // The sort attribute should be either 'ascending' or 'descending' - expect(['ascending', 'descending']).toContain(newSortAttribute); + // The sort attribute should have changed + expect(newSortAttribute).not.toBe(initialSortAttribute); - // Also verify the data is sorted by checking cell values - const cells = container.querySelectorAll('[role="gridcell"]'); - const firstColumnCells = Array.from(cells).filter((_, index) => index % 2 === 0); + // The sort attribute should be either 'ascending' or 'descending' + expect(['ascending', 'descending']).toContain(newSortAttribute); - // Get the text content of the first column cells - const cellValues = firstColumnCells.map((cell) => cell.textContent); + // Also verify the data is sorted by checking cell values + const cells = container.querySelectorAll('[role="gridcell"]'); + const firstColumnCells = Array.from(cells).filter((_, index) => index % 2 === 0); - // Verify we have values to check - expect(cellValues.length).toBeGreaterThan(0); + // Get the text content of the first column cells + const cellValues = firstColumnCells.map((cell) => cell.textContent); - // Verify the values are in sorted order based on the aria-sort attribute - const sortedValues = [...cellValues].sort(); + // Verify we have values to check + expect(cellValues.length).toBeGreaterThan(0); - if (newSortAttribute === 'ascending') { - expect(JSON.stringify(cellValues)).toBe(JSON.stringify(sortedValues)); - } else if (newSortAttribute === 'descending') { - expect(JSON.stringify(cellValues)).toBe(JSON.stringify([...sortedValues].reverse())); - } + // Verify the values are in sorted order based on the aria-sort attribute + const sortedValues = [...cellValues].sort(); + + if (newSortAttribute === 'ascending') { + expect(JSON.stringify(cellValues)).toBe(JSON.stringify(sortedValues)); + } else if (newSortAttribute === 'descending') { + expect(JSON.stringify(cellValues)).toBe(JSON.stringify([...sortedValues].reverse())); } }); it('cycles through ascending, descending, and no sort states', async () => { - // Mock scrollIntoView - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - const { container } = render( ); @@ -733,10 +729,7 @@ describe('TableNG', () => { } }); - it('supports multi-column sorting with shift key', async () => { - // Mock scrollIntoView - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - + it('supports multi-column sorting with cmd or ctrl key', async () => { const { container } = render( ); @@ -831,7 +824,7 @@ describe('TableNG', () => { expect(categoryBValues).toContain('4'); // 2. Now add second sort column (Value) with shift key - await user.keyboard('{Shift>}'); + await user.keyboard('{Control>}'); await user.click(valueColumnButton); // Check data is sorted by Category and then by Value @@ -866,7 +859,6 @@ describe('TableNG', () => { expect(multiSortedRows[4][2]).toBe('Alice'); // 3. Change Value sort direction to descending - await user.keyboard('{Shift>}'); await user.click(valueColumnButton); // Check data is sorted by Category (asc) and then by Value (desc) @@ -901,7 +893,6 @@ describe('TableNG', () => { expect(multiSortedRowsDesc[4][2]).toBe('Jane'); // 4. Test removing the secondary sort by clicking a third time - await user.keyboard('{Shift>}'); await user.click(valueColumnButton); // The data should still be sorted by Category only @@ -915,6 +906,56 @@ describe('TableNG', () => { // Last 2 rows should still be 'B' category expect(singleSortRows[3][0]).toBe('B'); expect(singleSortRows[4][0]).toBe('B'); + + // finally release control and prove that we exit multi-sort mode + await user.keyboard('{/Control}'); + await user.click(categoryColumnButton); + + const nonMultiSortCategoryRows = getCellTextContent(); + + expect(nonMultiSortCategoryRows[0][0]).toBe('B'); + expect(nonMultiSortCategoryRows[0][1]).toBe('3'); + expect(nonMultiSortCategoryRows[0][2]).toBe('Jane'); + + expect(nonMultiSortCategoryRows[1][0]).toBe('B'); + expect(nonMultiSortCategoryRows[1][1]).toBe('4'); + expect(nonMultiSortCategoryRows[1][2]).toBe('Alice'); + + expect(nonMultiSortCategoryRows[2][0]).toBe('A'); + expect(nonMultiSortCategoryRows[2][1]).toBe('5'); + expect(nonMultiSortCategoryRows[2][2]).toBe('John'); + + expect(nonMultiSortCategoryRows[3][0]).toBe('A'); + expect(nonMultiSortCategoryRows[3][1]).toBe('1'); + expect(nonMultiSortCategoryRows[3][2]).toBe('Bob'); + + expect(nonMultiSortCategoryRows[4][0]).toBe('A'); + expect(nonMultiSortCategoryRows[4][1]).toBe('2'); + expect(nonMultiSortCategoryRows[4][2]).toBe('Charlie'); + + await user.click(valueColumnButton); + + const nonMultiSortValueRows = getCellTextContent(); + + expect(nonMultiSortValueRows[0][0]).toBe('A'); + expect(nonMultiSortValueRows[0][1]).toBe('1'); + expect(nonMultiSortValueRows[0][2]).toBe('Bob'); + + expect(nonMultiSortValueRows[1][0]).toBe('A'); + expect(nonMultiSortValueRows[1][1]).toBe('2'); + expect(nonMultiSortValueRows[1][2]).toBe('Charlie'); + + expect(nonMultiSortValueRows[2][0]).toBe('B'); + expect(nonMultiSortValueRows[2][1]).toBe('3'); + expect(nonMultiSortValueRows[2][2]).toBe('Jane'); + + expect(nonMultiSortValueRows[3][0]).toBe('B'); + expect(nonMultiSortValueRows[3][1]).toBe('4'); + expect(nonMultiSortValueRows[3][2]).toBe('Alice'); + + expect(nonMultiSortValueRows[4][0]).toBe('A'); + expect(nonMultiSortValueRows[4][1]).toBe('5'); + expect(nonMultiSortValueRows[4][2]).toBe('John'); }); it('correctly sorts different data types', async () => { @@ -978,6 +1019,38 @@ describe('TableNG', () => { // Verify number values are sorted numerically expect(numberValues).toEqual(['1', '2', '3']); }); + + it('triggers the onSortByChange callback', async () => { + const onSortByChange = jest.fn(); + + const { container } = render( + + ); + + // Ensure there are column headers + const columnHeader = container.querySelector('[role="columnheader"]'); + expect(columnHeader).toBeInTheDocument(); + + // Find the sort button within the first header + if (!columnHeader) { + throw new Error('No column header found'); + } + + // Look for a button inside the header + const sortButton = columnHeader.querySelector('button') || columnHeader; + + // Click the sort button + await user.click(sortButton); + + // After clicking, the header should have an aria-sort attribute + expect(onSortByChange).toHaveBeenCalledTimes(1); + }); }); describe('Filtering', () => { @@ -1159,8 +1232,28 @@ describe('TableNG', () => { }); }); - describe('Resizing', () => { - it('calls onColumnResize when column is resized', () => { + // TODO we need to test this with an e2e rather than a unit test, because the element dimensions calcs + // don't work in unit tests (no clientWidth/Height) + describe.skip('Resizing', () => { + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + window.HTMLElement.prototype.setPointerCapture = jest.fn(); + window.HTMLElement.prototype.hasPointerCapture = jest.fn(); + window.HTMLElement.prototype.releasePointerCapture = jest.fn(); + window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 100, + height: 20, + top: 0, + left: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON: jest.fn(() => ''), + })); + }); + + it('calls onColumnResize when column is resized', async () => { const onColumnResize = jest.fn(); const { container } = render( @@ -1174,22 +1267,19 @@ describe('TableNG', () => { ); // Find resize handle - const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] .rdg-resizer'); + const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] > div:last-child'); + const handle = resizeHandles[0]; - // TODO: This `if` doesn't even trigger - the test is evergreen. - // We should work out a reliable way to actually find and trigger the resize methods - // The querySelector doesn't return anything! - if (resizeHandles.length > 0) { - // Simulate resize by triggering mousedown, mousemove, mouseup - /* eslint-disable testing-library/prefer-user-event */ - fireEvent.mouseDown(resizeHandles[0]); - fireEvent.mouseMove(resizeHandles[0], { clientX: 250 }); - fireEvent.mouseUp(resizeHandles[0]); - /* eslint-enable testing-library/prefer-user-event */ - - // Check that onColumnResize was called - expect(onColumnResize).toHaveBeenCalled(); + if (!handle) { + throw new Error('Resize handle not found'); } + + // simulate a click, then drag, then release. + await userEvent.pointer({ keys: '[MouseLeft>]', coords: { x: 0, y: 0 }, target: handle }); + await userEvent.pointer({ coords: { x: 250, y: 0 }, target: handle }); + await userEvent.pointer({ keys: '[/MouseLeft]', coords: { x: 250, y: 0 }, target: handle }); + + await waitFor(() => expect(onColumnResize).toHaveBeenCalled()); }); }); @@ -1201,7 +1291,7 @@ describe('TableNG', () => { const cells = container.querySelectorAll('[role="gridcell"]'); const cellStyles = window.getComputedStyle(cells[0]); - expect(cellStyles.getPropertyValue('white-space')).toBe('nowrap'); + expect(cellStyles.getPropertyValue('white-space')).not.toBe('pre-line'); }); it('applies text wrapping styles when wrapText is true', () => { @@ -1265,6 +1355,10 @@ describe('TableNG', () => { // Check for the Inspect value menu item const menuItem = await screen.findByText('Inspect value'); expect(menuItem).toBeInTheDocument(); + + // close the menu + await userEvent.click(container); + expect(menuItem).not.toBeInTheDocument(); }); }); @@ -1371,8 +1465,8 @@ describe('TableNG', () => { expect(cells.length).toBeGreaterThan(0); // Check the first div inside the cell for style attributes - const div = cells[0].querySelectorAll('div')[0]; - const styleAttr = window.getComputedStyle(div); + const cell = cells[0]; + const styleAttr = window.getComputedStyle(cell); // Expected color is red expect(styleAttr.background).toBe('rgb(255, 0, 0)'); @@ -1408,11 +1502,50 @@ describe('TableNG', () => { expect(cells.length).toBeGreaterThan(0); // Check the first div inside the cell for style attributes - const div = cells[0].querySelectorAll('div')[0]; - const computedStyle = window.getComputedStyle(div); + const cell = cells[0]; + const computedStyle = window.getComputedStyle(cell); // Expected color is red expect(computedStyle.color).toBe('rgb(255, 0, 0)'); + + // doesn't accidentally applyToRow + const otherCell = cells[1]; + expect(window.getComputedStyle(otherCell).color).not.toBe('rgb(255, 0, 0)'); + }); + + it("renders the background color correclty when using 'ColorBackground' display mode and applyToRow is true", () => { + // Create a frame with color background cells and applyToRow set to true + const frame = createBasicDataFrame(); + frame.fields[0].config.custom = { + ...frame.fields[0].config.custom, + cellOptions: { + type: TableCellDisplayMode.ColorBackground, + applyToRow: true, + mode: TableCellBackgroundDisplayMode.Basic, + }, + }; + + // Add color to the display values + const originalDisplay = frame.fields[0].display; + const expectedColor = '#ff0000'; // Red color + frame.fields[0].display = (value: unknown) => { + const displayValue = originalDisplay ? originalDisplay(value) : { text: String(value), numeric: 0 }; + return { + ...displayValue, + color: expectedColor, + }; + }; + + const { container } = render(); + + // Find rows in the table + const rows = container.querySelectorAll('[role="row"]'); + const cells = rows[1].querySelectorAll('[role="gridcell"]'); // Skip header row + for (const cell of cells) { + const cellStyle = window.getComputedStyle(cell); + // Ensure each cell has the same background color + expect(cellStyle.backgroundColor).toBe('rgb(255, 0, 0)'); + } }); }); @@ -1451,44 +1584,77 @@ describe('TableNG', () => { jest.clearAllMocks(); }); - it('should publish DataHoverEvent when hovering over a row with time field', () => { - const frame = createTimeDataFrame(); - const idx = 1; - - onRowHover(idx, mockPanelContext, frame, true); - - expect(mockEventBus.publish).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { - point: { - time: new Date('2024-03-20T10:01:00Z').getTime(), - }, - }, - type: 'data-hover', - }) + it('should publish DataHoverEvent when hovering over a row with time field', async () => { + const data = createTimeDataFrame(); + render( + + + ); + + await userEvent.hover(screen.getAllByRole('row')[1]); + + expect(mockEventBus.publish).toHaveBeenCalledWith({ + payload: { + point: { + time: data.fields[0].values[0], + }, + }, + type: 'data-hover', + }); }); - it('should not publish DataHoverEvent when enableSharedCrosshair is false', () => { - const frame = createTimeDataFrame(); - const idx = 1; + it('should not publish DataHoverEvent when enableSharedCrosshair is false', async () => { + render( + + + + ); - onRowHover(idx, mockPanelContext, frame, false); + await userEvent.hover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).not.toHaveBeenCalled(); }); - it('should not publish DataHoverEvent when time field is not present', () => { - const frame = createBasicDataFrame(); - const idx = 1; + it('should not publish DataHoverEvent when time field is not present', async () => { + render( + + + + ); - onRowHover(idx, mockPanelContext, frame, true); + await userEvent.hover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).not.toHaveBeenCalled(); }); - it('should publish DataHoverClearEvent when leaving a row', () => { - onRowLeave(mockPanelContext, true); + it('should publish DataHoverClearEvent when leaving a row', async () => { + render( + + + + ); + + await userEvent.hover(screen.getAllByRole('row')[1]); + await userEvent.unhover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ @@ -1497,50 +1663,23 @@ describe('TableNG', () => { ); }); - it('should not publish DataHoverClearEvent when enableSharedCrosshair is false', () => { - onRowLeave(mockPanelContext, false); + it('should not publish DataHoverClearEvent when enableSharedCrosshair is false', async () => { + render( + + + + ); + + await userEvent.hover(screen.getAllByRole('row')[1]); + await userEvent.unhover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).not.toHaveBeenCalled(); }); }); - describe('scroll position persistence', () => { - it('should persist scroll position after revId change', () => { - const data = createBasicDataFrame(); - const { rerender } = render(); - - // Find the DataGrid element - const dataGrid = screen.getByRole('grid'); - - // Simulate scrolling - - fireEvent.scroll(dataGrid, { - target: { - scrollLeft: 100, - scrollTop: 50, - }, - }); - - // Rerender with the same data but different fieldConfig to trigger revId change - rerender( - - ); - - // Verify scroll position was restored - expect(dataGrid.scrollLeft).toBe(100); - expect(dataGrid.scrollTop).toBe(50); - }); - }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 22ed8d6105c..d2e38be4f58 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -1,23 +1,20 @@ import 'react-data-grid/lib/styles.css'; -import { css } from '@emotion/css'; -import { useMemo, useState, useLayoutEffect, useCallback, useRef, useEffect } from 'react'; -import { DataGrid, RenderCellProps, RenderRowProps, Row, SortColumn, DataGridHandle } from 'react-data-grid'; -import { useMeasure } from 'react-use'; - +import { css, cx } from '@emotion/css'; +import { Property } from 'csstype'; +import { Key, useLayoutEffect, useMemo, useState } from 'react'; import { - DataFrame, - DataHoverClearEvent, - DataHoverEvent, - Field, - fieldReducers, - FieldType, - formattedValueToString, - getDefaultTimeRange, - GrafanaTheme2, - ReducerID, -} from '@grafana/data'; + Cell, + CellRendererProps, + DataGrid, + DataGridProps, + RenderCellProps, + RenderRowProps, + Row, + SortColumn, +} from 'react-data-grid'; + +import { DataHoverClearEvent, DataHoverEvent, Field, FieldType, GrafanaTheme2, ReducerID } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { TableCellDisplayMode } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; import { ContextMenu } from '../../ContextMenu/ContextMenu'; @@ -25,137 +22,87 @@ import { MenuItem } from '../../Menu/MenuItem'; import { Pagination } from '../../Pagination/Pagination'; import { PanelContext, usePanelContext } from '../../PanelChrome'; import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector'; +import { CellColors } from '../types'; import { HeaderCell } from './Cells/HeaderCell'; import { RowExpander } from './Cells/RowExpander'; -import { TableCellNG } from './Cells/TableCellNG'; +import { TableCellActions } from './Cells/TableCellActions'; +import { getCellRenderer } from './Cells/renderers'; import { COLUMN, TABLE } from './constants'; import { - TableNGProps, - FilterType, - TableRow, - TableSummaryRow, - ColumnTypes, - TableColumnResizeActionCallback, - TableColumn, - TableFieldOptionsType, - ScrollPosition, - CellColors, -} from './types'; + useColumnResize, + useFilteredRows, + useFooterCalcs, + usePaginatedRows, + useRowHeight, + useSortedRows, + useTextWraps, +} from './hooks'; +import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types'; import { frameToRecords, - getCellColors, - getCellHeightCalculator, - getComparator, getDefaultRowHeight, getDisplayName, - getFooterItemNG, - getFooterStyles, getIsNestedTable, - getRowHeight, getTextAlign, - handleSort, - MapFrameToGridOptions, - processNestedTableRows, + getVisibleFields, shouldTextOverflow, + getApplyToRowBgFn, + getColumnTypes, + computeColWidths, + applySort, + getCellColors, + getCellOptions, } from './utils'; export function TableNG(props: TableNGProps) { const { cellHeight, + data, enablePagination, - enableVirtualization = true, - fieldConfig, + enableSharedCrosshair = false, + enableVirtualization, footerOptions, + getActions, height, initialSortBy, noHeader, onCellFilterAdded, onColumnResize, onSortByChange, - width, - data, - enableSharedCrosshair, - showTypeIcons, replaceVariables, + showTypeIcons, + structureRev, + width, } = props; - const initialSortColumns = useMemo(() => { - const initialSort = initialSortBy?.map(({ displayName, desc }) => { - const matchingField = data.fields.find(({ state }) => state?.displayName === displayName); - const columnKey = matchingField?.name || displayName; - - return { - columnKey, - direction: desc ? ('DESC' as const) : ('ASC' as const), - }; - }); - return initialSort ?? []; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - /* ------------------------------- Local state ------------------------------ */ - const [revId, setRevId] = useState(0); - const [contextMenuProps, setContextMenuProps] = useState<{ - rowIdx?: number; - value: string; - mode?: TableCellInspectorMode.code | TableCellInspectorMode.text; - top?: number; - left?: number; - } | null>(null); - const [isInspecting, setIsInspecting] = useState(false); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const [filter, setFilter] = useState({}); - const [page, setPage] = useState(0); - // This state will trigger re-render for recalculating row heights - const [, setResizeTrigger] = useState(0); - const [, setReadyForRowHeightCalc] = useState(false); - const [sortColumns, setSortColumns] = useState(initialSortColumns); - const [expandedRows, setExpandedRows] = useState([]); - const [isNestedTable, setIsNestedTable] = useState(false); - const scrollPositionRef = useRef({ x: 0, y: 0 }); - const [hasScroll, setHasScroll] = useState(false); - - /* ------------------------------- Local refs ------------------------------- */ - const crossFilterOrder = useRef([]); - const crossFilterRows = useRef>({}); - const headerCellRefs = useRef>({}); - // TODO: This ref persists sortColumns between renders. setSortColumns is still used to trigger re-render - const sortColumnsRef = useRef(initialSortColumns); - const prevProps = useRef(props); - const calcsRef = useRef([]); - const [paginationWrapperRef, { height: paginationHeight }] = useMeasure(); - const theme = useTheme2(); + const styles = useStyles2(getGridStyles, { + enablePagination, + noHeader, + }); const panelContext = usePanelContext(); - const isFooterVisible = Boolean(footerOptions?.show && footerOptions.reducer?.length); + const hasHeader = !noHeader; + const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length); const isCountRowsSet = Boolean( footerOptions?.countRows && footerOptions.reducer && footerOptions.reducer.length && footerOptions.reducer[0] === ReducerID.count ); - const tableRef = useRef(null); - /* --------------------------------- Effects -------------------------------- */ - useEffect(() => { - // TODO: there is a use case when adding a new column to the table doesn't update the table - if ( - prevProps.current.data.fields.length !== props.data.fields.length || - prevProps.current.fieldConfig?.overrides !== fieldConfig?.overrides || - prevProps.current.fieldConfig?.defaults !== fieldConfig?.defaults - ) { - setRevId(revId + 1); - } - prevProps.current = props; - }, [props, revId, fieldConfig?.overrides, fieldConfig?.defaults]); // eslint-disable-line react-hooks/exhaustive-deps + const [contextMenuProps, setContextMenuProps] = useState(null); + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + + const resizeHandler = useColumnResize(onColumnResize); useLayoutEffect(() => { if (!isContextMenuOpen) { return; } - function onClick(event: MouseEvent) { + function onClick(_event: MouseEvent) { setIsContextMenuOpen(false); } @@ -166,427 +113,90 @@ export function TableNG(props: TableNGProps) { }; }, [isContextMenuOpen]); - useEffect(() => { - const hasNestedFrames = getIsNestedTable(props.data); - setIsNestedTable(hasNestedFrames); - }, [props.data]); + const rows = useMemo(() => frameToRecords(data), [data]); + const columnTypes = useMemo(() => getColumnTypes(data.fields), [data.fields]); + const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]); - useEffect(() => { - const el = tableRef.current; - if (el) { - const gridElement = el?.element; - if (gridElement) { - setHasScroll( - gridElement.scrollHeight > gridElement.clientHeight || gridElement.scrollWidth > gridElement.clientWidth - ); - } - } - }, []); + const { + rows: filteredRows, + filter, + setFilter, + crossFilterOrder, + crossFilterRows, + } = useFilteredRows(rows, data.fields, { hasNestedFrames }); - // TODO: this is a hack to force the column width to update when the fieldConfig changes - const columnWidth = useMemo(() => { - setRevId(revId + 1); - return fieldConfig?.defaults?.custom?.width || 'auto'; - }, [fieldConfig]); // eslint-disable-line react-hooks/exhaustive-deps + const { + rows: sortedRows, + sortColumns, + setSortColumns, + } = useSortedRows(filteredRows, data.fields, { columnTypes, hasNestedFrames, initialSortBy }); - const defaultRowHeight = getDefaultRowHeight(theme, cellHeight); - const defaultLineHeight = theme.typography.body.lineHeight * theme.typography.fontSize; - const panelPaddingHeight = theme.components.panel.padding * theme.spacing.gridSize * 2; + const defaultRowHeight = useMemo(() => getDefaultRowHeight(theme, cellHeight), [theme, cellHeight]); + const [isInspecting, setIsInspecting] = useState(false); + const [expandedRows, setExpandedRows] = useState>({}); - /* ------------------------------ Rows & Columns ----------------------------- */ - const rows = useMemo(() => frameToRecords(props.data), [frameToRecords, props.data]); // eslint-disable-line react-hooks/exhaustive-deps - - // Create a map of column key to column type - const columnTypes = useMemo( - () => props.data.fields.reduce((acc, field) => ({ ...acc, [getDisplayName(field)]: field.type }), {}), - [props.data.fields] + // vt scrollbar accounting for column auto-sizing + const visibleFields = useMemo(() => getVisibleFields(data.fields), [data.fields]); + const visibleFieldsByDisplayName: Record = useMemo( + () => visibleFields.reduce((acc, f) => ({ ...acc, [getDisplayName(f)]: f }), {}), + [visibleFields] ); + const availableWidth = useMemo( + () => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width), + [width, hasNestedFrames] + ); + const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]); + const rowHeight = useRowHeight(widths, visibleFields, hasNestedFrames, defaultRowHeight, expandedRows); + + const { + rows: paginatedRows, + page, + setPage, + numPages, + pageRangeStart, + pageRangeEnd, + smallPagination, + } = usePaginatedRows(sortedRows, { + enabled: enablePagination, + width: availableWidth, + height, + hasHeader, + hasFooter, + rowHeight, + }); // Create a map of column key to text wrap - const textWraps = useMemo( + 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]); + + const renderRow = useMemo( + () => renderRowFactory(data.fields, panelContext, expandedRows, enableSharedCrosshair), + [data, enableSharedCrosshair, expandedRows, panelContext] + ); + + const renderCell = useMemo( + () => renderCellFactory(columnTypes, applyToRowBgFn, rowHeight, textWraps, theme, visibleFieldsByDisplayName), + [columnTypes, applyToRowBgFn, rowHeight, textWraps, theme, visibleFieldsByDisplayName] + ); + + const commonDataGridProps = useMemo( () => - props.data.fields.reduce<{ [key: string]: boolean }>( - (acc, field) => ({ - ...acc, - [getDisplayName(field)]: field.config?.custom?.cellOptions?.wrapText ?? false, - }), - {} - ), - [props.data.fields] - ); - - const textWrap = useMemo(() => Object.values(textWraps).some(Boolean), [textWraps]); - const styles = useStyles2(getStyles); - - // Create a function to get column widths for text wrapping calculations - const getColumnWidths = useCallback(() => { - const widths: Record = {}; - - // Set default widths from field config if they exist - props.data.fields.forEach((field) => { - const displayName = getDisplayName(field); - const configWidth = field.config?.custom?.width; - const totalWidth = typeof configWidth === 'number' ? configWidth : COLUMN.DEFAULT_WIDTH; - // subtract out padding and 1px right border - const contentWidth = totalWidth - 2 * TABLE.CELL_PADDING - 1; - widths[displayName] = contentWidth; - }); - - // Measure actual widths if available - Object.keys(headerCellRefs.current).forEach((key) => { - const headerCell = headerCellRefs.current[key]; - - if (headerCell.offsetWidth > 0) { - widths[key] = headerCell.offsetWidth; - } - }); - - return widths; - }, [props.data.fields]); - - const headersLength = useMemo(() => { - return props.data.fields.length; - }, [props.data.fields]); - - const fieldDisplayType = useMemo(() => { - return props.data.fields.reduce>((acc, field) => { - if (field.config?.custom?.cellOptions?.type) { - acc[getDisplayName(field)] = field.config.custom.cellOptions.type; - } - return acc; - }, {}); - }, [props.data.fields]); - - // Clean up fieldsData to simplify - const fieldsData = useMemo( - () => ({ - headersLength, - textWraps, - columnTypes, - fieldDisplayType, - columnWidths: getColumnWidths(), - }), - [textWraps, columnTypes, getColumnWidths, headersLength, fieldDisplayType] - ); - - // Filter rows - const filteredRows = useMemo(() => { - const filterValues = Object.entries(filter); - if (filterValues.length === 0) { - // reset cross filter order - crossFilterOrder.current = []; - return rows; - } - - // Helper function to get displayed value - const getDisplayedValue = (row: TableRow, key: string) => { - const field = props.data.fields.find((field) => getDisplayName(field) === key); - if (!field || !field.display) { - return ''; - } - const displayedValue = formattedValueToString(field.display(row[key])); - return displayedValue; - }; - - // Update crossFilterOrder - const filterKeys = new Set(filterValues.map(([key]) => key)); - filterKeys.forEach((key) => { - if (!crossFilterOrder.current.includes(key)) { - // Each time a filter is added or removed, it is always a single filter. - // When adding a new filter, it is always appended to the end, maintaining the order. - crossFilterOrder.current.push(key); - } - }); - // Remove keys from crossFilterOrder that are no longer present in the current filter values - crossFilterOrder.current = crossFilterOrder.current.filter((key) => filterKeys.has(key)); - - // reset crossFilterRows - crossFilterRows.current = {}; - - // For nested tables, only filter parent rows and keep their children - if (isNestedTable) { - return processNestedTableRows(rows, (parents) => - parents.filter((row) => { - for (const [key, value] of filterValues) { - const displayedValue = getDisplayedValue(row, key); - if (!value.filteredSet.has(displayedValue)) { - return false; - } - // collect rows for crossFilter - if (!crossFilterRows.current[key]) { - crossFilterRows.current[key] = [row]; - } else { - crossFilterRows.current[key].push(row); - } - } - return true; - }) - ); - } - - // Regular filtering for non-nested tables - return rows.filter((row) => { - for (const [key, value] of filterValues) { - const displayedValue = getDisplayedValue(row, key); - if (!value.filteredSet.has(displayedValue)) { - return false; - } - // collect rows for crossFilter - if (!crossFilterRows.current[key]) { - crossFilterRows.current[key] = [row]; - } else { - crossFilterRows.current[key].push(row); - } - } - return true; - }); - }, [rows, filter, isNestedTable, props.data.fields]); - - // Sort rows - const sortedRows = useMemo(() => { - if (sortColumns.length === 0) { - return filteredRows; - } - - // Common sort comparator function - const compareRows = (a: TableRow, b: TableRow): number => { - let result = 0; - for (let i = 0; i < sortColumns.length; i++) { - const { columnKey, direction } = sortColumns[i]; - const compare = getComparator(columnTypes[columnKey]); - const sortDir = direction === 'ASC' ? 1 : -1; - - result = sortDir * compare(a[columnKey], b[columnKey]); - if (result !== 0) { - break; - } - } - return result; - }; - - // Handle nested tables - if (isNestedTable) { - return processNestedTableRows(filteredRows, (parents) => [...parents].sort(compareRows)); - } - - // Regular sort for tables without nesting - return filteredRows.slice().sort((a, b) => compareRows(a, b)); - }, [filteredRows, sortColumns, columnTypes, isNestedTable]); - - // Paginated rows - // TODO consolidate calculations into pagination wrapper component and only use when needed - const numRows = sortedRows.length; - // calculate number of rowsPerPage based on height stack - let headerCellHeight = TABLE.MAX_CELL_HEIGHT; - if (noHeader) { - headerCellHeight = 0; - } else if (!noHeader && Object.keys(headerCellRefs.current).length > 0) { - headerCellHeight = headerCellRefs.current[Object.keys(headerCellRefs.current)[0]].getBoundingClientRect().height; - } - let rowsPerPage = Math.floor( - (height - headerCellHeight - TABLE.SCROLL_BAR_WIDTH - paginationHeight - panelPaddingHeight) / defaultRowHeight - ); - // if footer calcs are on, remove one row per page - if (isFooterVisible) { - rowsPerPage -= 1; - } - if (rowsPerPage < 1) { - // avoid 0 or negative rowsPerPage - rowsPerPage = 1; - } - const numberOfPages = Math.ceil(numRows / rowsPerPage); - if (page > numberOfPages) { - // resets pagination to end - setPage(numberOfPages - 1); - } - // calculate row range for pagination summary display - const itemsRangeStart = page * rowsPerPage + 1; - let displayedEnd = itemsRangeStart + rowsPerPage - 1; - if (displayedEnd > numRows) { - displayedEnd = numRows; - } - const smallPagination = width < TABLE.PAGINATION_LIMIT; - - const paginatedRows = useMemo(() => { - const pageOffset = page * rowsPerPage; - return sortedRows.slice(pageOffset, pageOffset + rowsPerPage); - }, [rows, sortedRows, page, rowsPerPage]); // eslint-disable-line react-hooks/exhaustive-deps - - useMemo(() => { - calcsRef.current = props.data.fields.map((field, index) => { - if (field.state?.calcs) { - delete field.state?.calcs; - } - if (isCountRowsSet) { - return index === 0 ? `${sortedRows.length}` : ''; - } - if (index === 0) { - const footerCalcReducer = footerOptions?.reducer?.[0]; - return footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : ''; - } - return getFooterItemNG(sortedRows, field, footerOptions); - }); - }, [sortedRows, props.data.fields, footerOptions, isCountRowsSet]); // eslint-disable-line react-hooks/exhaustive-deps - - const onCellExpand = (rowIdx: number) => { - if (!expandedRows.includes(rowIdx)) { - setExpandedRows([...expandedRows, rowIdx]); - } else { - setExpandedRows(expandedRows.filter((id) => id !== rowIdx)); - } - setResizeTrigger((prev) => prev + 1); - }; - - const { ctx, avgCharWidth } = useMemo(() => { - const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - // set in grafana/data in createTypography.ts - const letterSpacing = 0.15; - - ctx.letterSpacing = `${letterSpacing}px`; - ctx.font = font; - let txt = - "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; - - return { - ctx, - font, - avgCharWidth, - }; - }, [theme.typography.fontSize, theme.typography.fontFamily]); - - const columns = useMemo( - () => - mapFrameToDataGrid({ - frame: props.data, - calcsRef, - options: { - columnTypes, - textWraps, - columnWidth, - crossFilterOrder, - crossFilterRows, - defaultLineHeight, - defaultRowHeight, - expandedRows, - filter, - headerCellRefs, - isCountRowsSet, - onCellFilterAdded, - ctx, - onSortByChange, - rows, - setContextMenuProps, - setFilter, - setIsInspecting, - setSortColumns, - sortColumnsRef, - styles, - theme, - showTypeIcons, - replaceVariables, - ...props, - }, - handlers: { - onCellExpand, - onColumnResize: onColumnResize!, - }, - // Adjust table width to account for the scroll bar width - availableWidth: width - (hasScroll ? TABLE.SCROLL_BAR_WIDTH + TABLE.SCROLL_BAR_MARGIN : 0), - }), - [props.data, calcsRef, filter, expandedRows, expandedRows.length, footerOptions, width, hasScroll, sortedRows] // eslint-disable-line react-hooks/exhaustive-deps - ); - - // This effect needed to set header cells refs before row height calculation - useLayoutEffect(() => { - setReadyForRowHeightCalc(Object.keys(headerCellRefs.current).length > 0); - }, [columns]); - - const renderMenuItems = () => { - return ( - <> - { - setIsInspecting(true); - }} - className={styles.menuItem} - /> - - ); - }; - - const cellHeightCalc = useMemo(() => { - return getCellHeightCalculator(ctx, defaultLineHeight, defaultRowHeight, TABLE.CELL_PADDING); - }, [ctx, defaultLineHeight, defaultRowHeight]); - - const calculateRowHeight = useCallback( - (row: TableRow) => { - // Logic for sub-tables - if (Number(row.__depth) === 1 && !expandedRows.includes(Number(row.__index))) { - return 0; - } else if (Number(row.__depth) === 1 && expandedRows.includes(Number(row.__index))) { - const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1; - - // Ensure we have a minimum height for the nested table even if data is empty - const rowCount = row.data?.length ?? 0; - return Math.max(defaultRowHeight, defaultRowHeight * (rowCount + headerCount)); - } - return getRowHeight(row, cellHeightCalc, avgCharWidth, defaultRowHeight, fieldsData); - }, - [expandedRows, avgCharWidth, defaultRowHeight, fieldsData, cellHeightCalc] - ); - - const handleScroll = (event: React.UIEvent) => { - const target = event.currentTarget; - scrollPositionRef.current = { - x: target.scrollLeft, - y: target.scrollTop, - }; - }; - - // Reset sortColumns when initialSortBy changes - useEffect(() => { - if (initialSortColumns.length > 0) { - setSortColumns(initialSortColumns); - } - }, [initialSortColumns]); - - // Restore scroll position after re-renders - useEffect(() => { - if (tableRef.current?.element) { - tableRef.current.element.scrollLeft = scrollPositionRef.current.x; - tableRef.current.element.scrollTop = scrollPositionRef.current.y; - } - }, [revId]); - - return ( - <> - - ref={tableRef} - className={styles.dataGrid} - // Default to true, overridden to false for testing - enableVirtualization={enableVirtualization} - key={`DataGrid${revId}`} - rows={enablePagination ? paginatedRows : sortedRows} - columns={columns} - headerRowHeight={noHeader ? 0 : undefined} - defaultColumnOptions={{ - sortable: true, + ({ + enableVirtualization, + defaultColumnOptions: { + minWidth: 50, resizable: true, - }} - rowHeight={textWrap || isNestedTable ? calculateRowHeight : defaultRowHeight} - // TODO: This doesn't follow current table behavior - style={{ width, height: height - (enablePagination ? paginationHeight : 0) }} - renderers={{ - renderRow: (key, props) => - myRowRenderer(key, props, expandedRows, panelContext, data, enableSharedCrosshair ?? false), - }} - onScroll={handleScroll} - onCellContextMenu={({ row, column }, event) => { + sortable: true, + // draggable: true, + }, + onCellContextMenu: ({ row, column }, event) => { + // in nested tables, it's possible for this event to trigger in a column header + // when holding Ctrl for multi-row sort. + if (column.key === 'expanded') { + return; + } + event.preventGridDefault(); // Do not show the default context menu event.preventDefault(); @@ -598,30 +208,278 @@ export function TableNG(props: TableNGProps) { top: event.clientY, left: event.clientX, }); + setIsContextMenuOpen(true); - }} - // sorting - sortColumns={sortColumns} - // footer - // TODO figure out exactly how this works - some array needs to be here for it to render regardless of renderSummaryCell() - bottomSummaryRows={isFooterVisible ? [{}] : undefined} - onColumnResize={() => { - // NOTE: This method is called continuously during the column resize drag operation, - // providing the current column width. There is no separate event for the end of the drag operation. - if (textWrap) { - // This is needed only when textWrap is enabled - // TODO: this is a hack to force rowHeight re-calculation - setResizeTrigger((prev) => prev + 1); - } - }} + }, + onColumnResize: resizeHandler, + onSortColumnsChange: (newSortColumns: SortColumn[]) => { + setSortColumns(newSortColumns); + onSortByChange?.( + newSortColumns.map(({ columnKey, direction }) => ({ + displayName: columnKey, + desc: direction === 'DESC', + })) + ); + }, + sortColumns, + rowHeight, + headerRowClass: styles.headerRow, + headerRowHeight: noHeader ? 0 : TABLE.HEADER_ROW_HEIGHT, + bottomSummaryRows: hasFooter ? [{}] : undefined, + }) satisfies Partial>, + [ + enableVirtualization, + resizeHandler, + sortColumns, + rowHeight, + styles.headerRow, + noHeader, + hasFooter, + setSortColumns, + onSortByChange, + ] + ); + + const columns = useMemo((): TableColumn[] => { + const columnsFromFields = (f: Field[], w: number[]): TableColumn[] => + f.map((field, i): TableColumn => { + const justifyContent = getTextAlign(field); + const footerStyles = getFooterStyles(justifyContent); + const displayName = getDisplayName(field); + const headerCellClass = getHeaderCellStyles(theme, justifyContent).headerCell; + const cellOptions = getCellOptions(field); + const renderFieldCell = getCellRenderer(field, cellOptions); + + const cellInspect = Boolean(field.config.custom?.inspect); + const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null); + const showActions = cellInspect || showFilters; + const width = w[i]; + const frame = data; + + // helps us avoid string cx and emotion per-cell + const cellActionClassName = showActions + ? cx( + 'table-cell-actions', + styles.cellActions, + justifyContent === 'flex-end' ? styles.cellActionsEnd : styles.cellActionsStart + ) + : undefined; + + return { + field, + key: displayName, + name: displayName, + width, + headerCellClass, + renderCell: (props: RenderCellProps): JSX.Element => { + // TODO: once per row + const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; + // TODO: defer until click? + const actions = getActions?.(frame, field, props.row.__index, replaceVariables); + + const rowIdx = props.row.__index; + const value = props.row[displayName]; + + return ( + <> + {renderFieldCell({ + actions, + cellOptions, + frame, + field, + height, + justifyContent, + rowIdx, + theme, + value, + width, + cellInspect, + showFilters, + })} + {showActions && ( + + )} + + ); + }, + renderHeaderCell: ({ column, sortDirection }): JSX.Element => ( + + ), + renderSummaryCell: () => { + if (isCountRowsSet && i === 0) { + return ( +
+ + Count + + {footerCalcs[i]} +
+ ); + } + return
{footerCalcs[i]}
; + }, + }; + }); + + const result: TableColumn[] = columnsFromFields(visibleFields, widths); + + // handle nested frames rendering from here. + if (!hasNestedFrames) { + 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 renderRow = renderRowFactory(firstNestedData.fields, panelContext, expandedRows, enableSharedCrosshair); + const expandedColumns = columnsFromFields( + firstNestedData.fields, + computeColWidths(firstNestedData.fields, availableWidth) + ); + + // If we have nested frames, we need to add a column for the row expansion + result.unshift({ + key: 'expanded', + name: '', + field: { + name: '', + type: FieldType.other, + config: {}, + values: [], + }, + cellClass(row) { + if (Number(row.__depth) !== 0) { + return styles.cellNested; + } + return; + }, + colSpan(args) { + return args.type === 'ROW' && Number(args.row.__depth) === 1 ? data.fields.length : 1; + }, + renderCell: ({ row }) => { + if (Number(row.__depth) === 0) { + return ( + { + setExpandedRows({ ...expandedRows, [row.__index]: !expandedRows[row.__index] }); + }} + /> + ); + } + + // 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); + + return ( + + {...commonDataGridProps} + className={cx(styles.grid, styles.gridNested)} + columns={expandedColumns} + rows={expandedRecords} + renderers={{ renderRow, renderCell }} + /> + ); + }, + width: COLUMN.EXPANDER_WIDTH, + minWidth: COLUMN.EXPANDER_WIDTH, + }); + + return result; + }, [ + availableWidth, + commonDataGridProps, + crossFilterOrder, + crossFilterRows, + data, + defaultRowHeight, + enableSharedCrosshair, + expandedRows, + filter, + footerCalcs, + getActions, + hasNestedFrames, + isCountRowsSet, + onCellFilterAdded, + panelContext, + replaceVariables, + renderCell, + rows, + rowHeight, + setFilter, + showTypeIcons, + sortColumns, + styles, + theme, + visibleFields, + widths, + ]); + + // 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]); + + // 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; + + return ( + <> + + {...commonDataGridProps} + className={styles.grid} + columns={structureRevColumns} + rows={paginatedRows} + onCellKeyDown={ + hasNestedFrames + ? (_, event) => { + if (event.isDefaultPrevented()) { + // skip parent grid keyboard navigation if nested grid handled it + event.preventGridDefault(); + } + } + : null + } + renderers={{ renderRow, renderCell }} /> {enablePagination && ( -
+
{ setPage(toPage - 1); @@ -629,6 +487,8 @@ export function TableNG(props: TableNGProps) { /> {!smallPagination && (
+ {/* TODO: once TableRT is deprecated, we can update the localiziation + string with the more consistent variable names */} {{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows @@ -641,7 +501,13 @@ export function TableNG(props: TableNGProps) { ( + setIsInspecting(true)} + className={styles.menuItem} + /> + )} focusOnOpen={false} /> )} @@ -660,415 +526,172 @@ export function TableNG(props: TableNGProps) { ); } -export function mapFrameToDataGrid({ - frame, - calcsRef, - options, - handlers, - availableWidth, -}: { - frame: DataFrame; - calcsRef: React.MutableRefObject; - options: MapFrameToGridOptions; - handlers: { onCellExpand: (rowIdx: number) => void; onColumnResize: TableColumnResizeActionCallback }; - availableWidth: number; -}): TableColumn[] { - const { - columnTypes, - textWraps, - crossFilterOrder, - crossFilterRows, - defaultLineHeight, - defaultRowHeight, - expandedRows, - filter, - headerCellRefs, - isCountRowsSet, - onCellFilterAdded, - ctx, - onSortByChange, - rows, - setContextMenuProps, - setFilter, - setIsInspecting, - setSortColumns, - sortColumnsRef, - styles, - theme, - timeRange, - getActions, - showTypeIcons, - replaceVariables, - } = options; - const { onCellExpand, onColumnResize } = handlers; +/** + * this is passed to the top-level `renderRow` prop on DataGrid. applies aria attributes and custom event handlers. + */ +const renderRowFactory = + ( + fields: Field[], + panelContext: PanelContext, + expandedRows: Record, + enableSharedCrosshair: boolean + ) => + (key: React.Key, props: RenderRowProps): React.ReactNode => { + const { row } = props; + const rowIdx = Number(row.__index); + const isExpanded = !!expandedRows[rowIdx]; - const columns: TableColumn[] = []; - const hasNestedFrames = getIsNestedTable(frame); + // Don't render non expanded child rows + if (Number(row.__depth) === 1 && !isExpanded) { + return null; + } - // If nested frames, add expansion control column - if (hasNestedFrames) { - const expanderField: Field = { - name: '', - type: FieldType.other, - config: {}, - values: [], - }; - columns.push({ - key: 'expanded', - name: '', - field: expanderField, - cellClass: styles.cell, - colSpan(args) { - return args.type === 'ROW' && Number(args.row.__depth) === 1 ? frame.fields.length : 1; - }, - renderCell: ({ row }) => { - // TODO add TableRow type extension to include row depth and optional data - if (Number(row.__depth) === 0) { - const rowIdx = Number(row.__index); - return ( - onCellExpand(rowIdx)} - isExpanded={expandedRows.includes(rowIdx)} - /> + // Add aria-expanded to parent rows that have nested data + if (row.data) { + return ; + } + + const handlers: Partial = {}; + if (enableSharedCrosshair) { + const timeField = fields.find((f) => f.type === FieldType.time); + if (timeField) { + handlers.onMouseEnter = () => { + panelContext.eventBus.publish( + new DataHoverEvent({ + point: { + time: timeField?.values[rowIdx], + }, + }) ); - } - // If it's a child, render entire DataGrid at first column position - let expandedColumns: TableColumn[] = []; - let expandedRecords: TableRow[] = []; - - // Type guard to check if data exists as it's optional - if (row.data) { - expandedColumns = mapFrameToDataGrid({ - frame: row.data, - calcsRef, - options: { ...options }, - handlers: { onCellExpand, onColumnResize }, - availableWidth, - }); - expandedRecords = frameToRecords(row.data); - } - - // TODO add renderHeaderCell HeaderCell's here and handle all features - return ( - - rows={expandedRecords} - columns={expandedColumns} - rowHeight={defaultRowHeight} - className={styles.dataGrid} - style={{ height: '100%', overflow: 'visible', marginLeft: COLUMN.EXPANDER_WIDTH - 1 }} - headerRowHeight={row.data?.meta?.custom?.noHeader ? 0 : undefined} - /> - ); - }, - width: COLUMN.EXPANDER_WIDTH, - minWidth: COLUMN.EXPANDER_WIDTH, - }); - - availableWidth -= COLUMN.EXPANDER_WIDTH; - } - - // Row background color function - let rowBg: Function | undefined = undefined; - for (const field of frame.fields) { - const fieldOptions = field.config.custom; - const cellOptionsExist = fieldOptions !== undefined && fieldOptions.cellOptions !== undefined; - - if ( - cellOptionsExist && - fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground && - fieldOptions.cellOptions.applyToRow - ) { - rowBg = (rowIndex: number): CellColors => { - const display = field.display!(field.values[rowIndex]); - const colors = getCellColors(theme, fieldOptions.cellOptions, display); - return colors; - }; - } - } - - let fieldCountWithoutWidth = 0; - frame.fields.map((field, fieldIndex) => { - if (field.type === FieldType.nestedFrames || field.config.custom?.hidden) { - // Don't render nestedFrames type field - return; - } - const fieldTableOptions: TableFieldOptionsType = field.config.custom || {}; - const key = getDisplayName(field); - const justifyColumnContent = getTextAlign(field); - const footerStyles = getFooterStyles(justifyColumnContent); - - // current/old table width logic calculations - if (fieldTableOptions.width) { - availableWidth -= fieldTableOptions.width; - } else { - fieldCountWithoutWidth++; - } - - // Add a column for each field - columns.push({ - key, - name: field.name, - field, - cellClass: textWraps[getDisplayName(field)] ? styles.cellWrapped : styles.cell, - renderCell: (props: RenderCellProps): JSX.Element => { - const { row } = props; - const cellType = field.config?.custom?.cellOptions?.type ?? TableCellDisplayMode.Auto; - const value = row[key]; - // Cell level rendering here - return ( - - shouldTextOverflow( - key, - row, - columnTypes, - headerCellRefs, - ctx, - defaultLineHeight, - defaultRowHeight, - TABLE.CELL_PADDING, - textWraps[getDisplayName(field)], - field, - cellType - ) - } - setIsInspecting={setIsInspecting} - setContextMenuProps={setContextMenuProps} - getActions={getActions} - rowBg={rowBg} - onCellFilterAdded={onCellFilterAdded} - replaceVariables={replaceVariables} - /> - ); - }, - renderSummaryCell: () => { - if (isCountRowsSet && fieldIndex === 0) { - return ( -
- - Count - - {calcsRef.current[fieldIndex]} -
- ); - } - return
{calcsRef.current[fieldIndex]}
; - }, - renderHeaderCell: ({ column, sortDirection }): JSX.Element => ( - { - handleSort(columnKey, direction, isMultiSort, setSortColumns, sortColumnsRef); - - // Update panel context with the new sort order - if (onSortByChange) { - const sortByFields = sortColumnsRef.current.map(({ columnKey, direction }) => ({ - displayName: columnKey, - desc: direction === 'DESC', - })); - onSortByChange(sortByFields); - } - }} - direction={sortDirection} - justifyContent={justifyColumnContent} - filter={filter} - setFilter={setFilter} - onColumnResize={onColumnResize} - headerCellRefs={headerCellRefs} - crossFilterOrder={crossFilterOrder} - crossFilterRows={crossFilterRows} - showTypeIcons={showTypeIcons} - /> - ), - width: fieldTableOptions.width, - minWidth: fieldTableOptions.minWidth || COLUMN.DEFAULT_WIDTH, - }); - }); - - // set columns that are at minimum width - let sharedWidth = availableWidth / fieldCountWithoutWidth; - for (let i = fieldCountWithoutWidth; i > 0; i--) { - for (const column of columns) { - if (!column.width && column.minWidth! > sharedWidth) { - column.width = column.minWidth; - availableWidth -= column.width!; - fieldCountWithoutWidth -= 1; - sharedWidth = availableWidth / fieldCountWithoutWidth; + }; + handlers.onMouseLeave = () => { + panelContext.eventBus.publish(new DataHoverClearEvent()); + }; } } - } - // divide up the rest of the space - for (const column of columns) { - if (!column.width) { - column.width = sharedWidth; + return ; + }; + +/** + * passed to the top-level `renderCell` prop on DataGrid. This applies all per-cell styles. + */ +const renderCellFactory = + ( + columnTypes: Record, + applyToRowBgFn: ((rowIdx: number) => CellColors) | undefined, + rowHeight: number | ((row: TableRow) => number), + textWraps: Record, + theme: GrafanaTheme2, + visibleFieldsByDisplayName: Record + ) => + (key: Key, props: CellRendererProps) => { + const displayName = props.column.key; + const field = visibleFieldsByDisplayName[displayName]; + + // exit early if we fail to look up the field from the column key. + if (!field) { + return ; } - column.minWidth = COLUMN.MIN_WIDTH; - } - return columns; -} + const cellOptions = getCellOptions(field); + const cellType = cellOptions.type; + const value = props.row[props.column.key]; -export function myRowRenderer( - key: React.Key, - props: RenderRowProps, - expandedRows: number[], - panelContext: PanelContext, - data: DataFrame, - enableSharedCrosshair: boolean -): React.ReactNode { - // Let's render row level things here! - // i.e. we can look at row styles and such here - const { row } = props; - const rowIdx = Number(row.__index); - const isExpanded = expandedRows.includes(rowIdx); + const colors: CellColors = (() => { + if (applyToRowBgFn) { + return applyToRowBgFn(props.rowIdx); + } + const displayValue = field.display?.(value); + if (displayValue && cellOptions) { + return getCellColors(theme, cellOptions, displayValue); + } + return {}; + })(); - // Don't render non expanded child rows - if (Number(row.__depth) === 1 && !isExpanded) { - return null; - } + const rh = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; + const shouldOverflow = shouldTextOverflow( + displayName, + columnTypes, + textWraps[getDisplayName(field)], + field, + cellType + ); + const shouldWrap = textWraps[displayName] ?? false; + const cellStyle = getCellStyles(theme, field, rh, shouldWrap, shouldOverflow, colors); - // Add aria-expanded to parent rows that have nested data - if (row.data) { - return ; - } + return ( + + ); + }; - return ( - onRowHover(rowIdx, panelContext, data, enableSharedCrosshair)} - onMouseLeave={() => onRowLeave(panelContext, enableSharedCrosshair)} - /> - ); -} - -export function onRowHover(idx: number, panelContext: PanelContext, frame: DataFrame, enableSharedCrosshair: boolean) { - if (!enableSharedCrosshair) { - return; - } - - const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!; - - if (!timeField) { - return; - } - - panelContext.eventBus.publish( - new DataHoverEvent({ - point: { - time: timeField.values[idx], - }, - }) - ); -} - -export function onRowLeave(panelContext: PanelContext, enableSharedCrosshair: boolean) { - if (!enableSharedCrosshair) { - return; - } - - panelContext.eventBus.publish(new DataHoverClearEvent()); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - dataGrid: css({ +const getGridStyles = ( + theme: GrafanaTheme2, + { enablePagination, noHeader }: { enablePagination?: boolean; noHeader?: boolean } +) => ({ + grid: css({ '--rdg-background-color': theme.colors.background.primary, '--rdg-header-background-color': theme.colors.background.primary, - '--rdg-border-color': 'transparent', + '--rdg-border-color': theme.isDark ? '#282b30' : '#ebebec', '--rdg-color': theme.colors.text.primary, - '&:hover': { - '--rdg-row-hover-background-color': theme.colors.emphasize(theme.colors.action.hover, 0.6), - }, - // If we rely solely on borderInlineEnd which is added from data grid, we - // get a small gap where the gridCell borders meet the column header borders. - // To avoid this, we can unset borderInlineEnd and set borderRight instead. - '.rdg-cell': { - borderInlineEnd: 'unset', - borderRight: `1px solid ${theme.colors.border.medium}`, + // note: this cannot have any transparency since default cells that + // overlay/overflow on hover inherit this background and need to occlude cells below + '--rdg-row-hover-background-color': theme.isDark ? '#212428' : '#f4f5f5', - '&:last-child': { - borderRight: 'none', - }, - }, + // TODO: magic 32px number is unfortunate. it would be better to have the content + // flow using flexbox rather than hard-coding this size via a calc + blockSize: enablePagination ? 'calc(100% - 32px)' : '100%', + scrollbarWidth: 'thin', + scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001', + + border: 'none', '.rdg-summary-row': { - backgroundColor: theme.colors.background.primary, - '--rdg-summary-border-color': theme.colors.border.medium, - '.rdg-cell': { - // Prevent collisions with custom cell components - zIndex: 2, - borderRight: 'none', + zIndex: theme.zIndex.tooltip - 1, + paddingInline: TABLE.CELL_PADDING, + paddingBlock: TABLE.CELL_PADDING, }, }, - - // Due to stylistic choices, we do not want borders on the column headers - // other than the bottom border. - 'div[role=columnheader]': { - borderBottom: `1px solid ${theme.colors.border.medium}`, - borderInlineEnd: 'unset', - - '.r1y6ywlx7-0-0-beta-46': { - '&:hover': { - borderRight: `3px solid ${theme.colors.text.link}`, - }, - }, - }, - - '::-webkit-scrollbar': { - width: TABLE.SCROLL_BAR_WIDTH, - height: TABLE.SCROLL_BAR_WIDTH, - }, - '::-webkit-scrollbar-thumb': { - backgroundColor: 'rgba(204, 204, 220, 0.16)', - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: '4px', - }, - '::-webkit-scrollbar-track': { - background: 'transparent', - }, - '::-webkit-scrollbar-corner': { - backgroundColor: 'transparent', + }), + gridNested: css({ + height: '100%', + width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - 1}px)`, + overflow: 'visible', + marginLeft: COLUMN.EXPANDER_WIDTH - 1, + }), + cellNested: css({ + '&[aria-selected=true]': { + outline: 'none', }, }), - menuItem: css({ - maxWidth: '200px', + 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.3)' : 'rgba(255, 255, 255, 0.7)', + padding: theme.spacing.x0_5, + paddingInlineStart: theme.spacing.x1, }), - cell: css({ - '--rdg-border-color': theme.colors.border.medium, - borderLeft: 'none', - whiteSpace: 'nowrap', - wordWrap: 'break-word', - overflow: 'hidden', - textOverflow: 'ellipsis', - - // Reset default cell styles for custom cell component styling - paddingInline: '0', + cellActionsEnd: css({ + left: 0, }), - cellWrapped: css({ - '--rdg-border-color': theme.colors.border.medium, - borderLeft: 'none', - whiteSpace: 'pre-line', - wordWrap: 'break-word', - overflow: 'hidden', - textOverflow: 'ellipsis', - - // Reset default cell styles for custom cell component styling - paddingInline: '0', + cellActionsStart: css({ + right: 0, + }), + headerRow: css({ + paddingBlockStart: 0, + fontWeight: 'normal', + ...(noHeader && { display: 'none' }), }), paginationContainer: css({ alignItems: 'center', @@ -1084,4 +707,65 @@ const getStyles = (theme: GrafanaTheme2) => ({ justifyContent: 'flex-end', padding: theme.spacing(0, 1, 0, 2), }), + menuItem: css({ + maxWidth: '200px', + }), +}); + +const getFooterStyles = (justifyContent: Property.JustifyContent) => ({ + footerCellCountRows: css({ + display: 'flex', + justifyContent: 'space-between', + }), + footerCell: css({ + display: 'flex', + justifyContent: justifyContent || 'space-between', + }), +}); + +const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ + headerCell: css({ + display: 'flex', + gap: theme.spacing(0.5), + zIndex: theme.zIndex.tooltip - 1, + paddingInline: TABLE.CELL_PADDING, + paddingBlock: TABLE.CELL_PADDING, + borderInlineEnd: 'none', + justifyContent, + }), +}); + +const getCellStyles = ( + theme: GrafanaTheme2, + field: Field, + rowHeight: number, + shouldWrap: boolean, + shouldOverflow: boolean, + colors: CellColors +) => ({ + cell: css({ + textOverflow: 'initial', + background: colors.bgColor ?? 'inherit', + alignContent: 'center', + justifyContent: getTextAlign(field), + paddingInline: TABLE.CELL_PADDING, + height: '100%', + minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container + ...(shouldWrap && { whiteSpace: 'pre-line' }), + '&:last-child': { + borderInlineEnd: 'none', + }, + '&:hover': { + background: colors.bgHoverColor, + '.table-cell-actions': { + display: 'flex', + }, + ...(shouldOverflow && { + zIndex: theme.zIndex.tooltip - 2, + whiteSpace: 'pre-line', + height: 'fit-content', + minWidth: 'fit-content', + }), + }, + }), }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/constants.ts b/packages/grafana-ui/src/components/Table/TableNG/constants.ts index 04e6acbdfb4..34490833395 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/constants.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/constants.ts @@ -8,7 +8,8 @@ export const COLUMN = { /** Table layout and display constants */ export const TABLE = { - CELL_PADDING: 8, + CELL_PADDING: 6, + HEADER_ROW_HEIGHT: 28, MAX_CELL_HEIGHT: 48, PAGINATION_LIMIT: 750, SCROLL_BAR_WIDTH: 8, diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts new file mode 100644 index 00000000000..51c702e5d60 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -0,0 +1,335 @@ +import { act, renderHook } from '@testing-library/react'; + +import { Field, FieldType } from '@grafana/data'; + +import { useFilteredRows, usePaginatedRows, useSortedRows, useFooterCalcs } from './hooks'; +import { getColumnTypes } from './utils'; + +describe('TableNG hooks', () => { + function setupData() { + // Mock data for testing + const fields: Field[] = [ + { + name: 'name', + type: FieldType.string, + display: (v) => ({ text: v as string, numeric: NaN }), + config: {}, + values: [], + }, + { + name: 'age', + type: FieldType.number, + display: (v) => ({ text: (v as number).toString(), numeric: v as number }), + config: {}, + values: [], + }, + { + name: 'active', + type: FieldType.boolean, + display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }), + config: {}, + values: [], + }, + ]; + + const rows = [ + { name: 'Alice', age: 30, active: true, __depth: 0, __index: 0 }, + { name: 'Bob', age: 25, active: false, __depth: 0, __index: 1 }, + { name: 'Charlie', age: 35, active: true, __depth: 0, __index: 2 }, + ]; + + return { fields, rows }; + } + + describe('useFilteredRows', () => { + it('should correctly initialize with provided fields and rows', () => { + const { fields, rows } = setupData(); + const { result } = renderHook(() => useFilteredRows(rows, fields, { hasNestedFrames: false })); + expect(result.current.rows[0].name).toBe('Alice'); + }); + + it('should apply filters correctly', () => { + const { fields, rows } = setupData(); + const { result } = renderHook(() => useFilteredRows(rows, fields, { hasNestedFrames: false })); + + act(() => { + result.current.setFilter({ + name: { filteredSet: new Set(['Alice']) }, + }); + }); + + expect(result.current.rows.length).toBe(1); + expect(result.current.rows[0].name).toBe('Alice'); + }); + + it('should clear filters correctly', () => { + const { fields, rows } = setupData(); + const { result } = renderHook(() => useFilteredRows(rows, fields, { hasNestedFrames: false })); + + act(() => { + result.current.setFilter({ + name: { filteredSet: new Set(['Alice']) }, + }); + }); + + expect(result.current.rows.length).toBe(1); + + act(() => { + result.current.setFilter({}); + }); + + expect(result.current.rows.length).toBe(3); + }); + + it.todo('should handle nested frames'); + }); + + 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 }], + }) + ); + + // Initial state checks + expect(result.current.sortColumns).toEqual([{ columnKey: 'age', direction: 'ASC' }]); + expect(result.current.rows[0].name).toBe('Bob'); + }); + + 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 }], + }) + ); + + expect(result.current.rows[0].name).toBe('Bob'); + + act(() => { + result.current.setSortColumns([{ columnKey: 'age', direction: 'DESC' }]); + }); + + expect(result.current.rows[0].name).toBe('Charlie'); + + act(() => { + result.current.setSortColumns([{ columnKey: 'name', direction: 'ASC' }]); + }); + + expect(result.current.rows[0].name).toBe('Alice'); + }); + + it.todo('should handle nested frames'); + }); + + describe('usePaginatedRows', () => { + 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 }) + ); + + expect(result.current.page).toBe(-1); + expect(result.current.rowsPerPage).toBe(0); + expect(result.current.pageRangeStart).toBe(1); + expect(result.current.pageRangeEnd).toBe(3); + expect(result.current.rows.length).toBe(3); + }); + + it('should handle pagination 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: 60, + width: 800, + rowHeight: 10, + }) + ); + + 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); + }); + }); + + describe('useFooterCalcs', () => { + const rows = [ + { Field1: 1, Text: 'a', __depth: 0, __index: 0 }, + { Field1: 2, Text: 'b', __depth: 0, __index: 1 }, + { Field1: 3, Text: 'c', __depth: 0, __index: 2 }, + { Field2: 3, Text: 'd', __depth: 0, __index: 3 }, + { Field2: 10, Text: 'e', __depth: 0, __index: 4 }, + ]; + + const numericField: Field = { + name: 'Field1', + type: FieldType.number, + values: [1, 2, 3], + config: { + custom: {}, + }, + display: (value: unknown) => ({ + text: String(value), + numeric: Number(value), + color: undefined, + prefix: undefined, + suffix: undefined, + }), + state: {}, + getLinks: undefined, + }; + + const numericField2: Field = { + name: 'Field2', + type: FieldType.number, + values: [3, 10], + config: { custom: {} }, + display: (value: unknown) => ({ + text: String(value), + numeric: Number(value), + color: undefined, + prefix: undefined, + suffix: undefined, + }), + state: {}, + getLinks: undefined, + }; + + const textField: Field = { + name: 'Text', + type: FieldType.string, + values: ['a', 'b', 'c'], + config: { custom: {} }, + display: (value: unknown) => ({ + text: String(value), + numeric: 0, + color: undefined, + prefix: undefined, + suffix: undefined, + }), + state: {}, + getLinks: undefined, + }; + + it('should calculate sum for numeric fields', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'] }, + }) + ); + + expect(result.current).toEqual(['Total', '6']); // 1 + 2 + 3 + }); + + it('should calculate mean for numeric fields', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField], { + enabled: true, + footerOptions: { show: true, reducer: ['mean'] }, + }) + ); + + expect(result.current).toEqual(['Mean', '2']); // (1 + 2 + 3) / 3 + }); + + it('should return an empty string for non-numeric fields', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'] }, + }) + ); + + expect(result.current).toEqual(['Total', '']); + }); + + it('should return empty array if no footerOptions are provided', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: undefined, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty array when footer is disabled', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: false, + footerOptions: { show: true, reducer: ['sum'] }, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty array when reducer is undefined', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: { show: true, reducer: undefined }, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty array when reducer is empty', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: { show: true, reducer: [] }, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty string if fields array doesnt include this field', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField, numericField2], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'], fields: ['Field2', 'Field3'] }, + }) + ); + + expect(result.current).toEqual(['Total', '', '13']); + }); + + it('should return the calculation if fields array includes this field', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField, numericField2], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'], fields: ['Field1', 'Field2', 'Field3'] }, + }) + ); + + expect(result.current).toEqual(['Total', '6', '13']); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts new file mode 100644 index 00000000000..865a4d01c67 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -0,0 +1,506 @@ +import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } from 'react'; +import { Column, DataGridProps, SortColumn } from 'react-data-grid'; + +import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; + +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'; + +// Helper function to get displayed value +const getDisplayedValue = (row: TableRow, key: string, fields: Field[]) => { + const field = fields.find((field) => getDisplayName(field) === key); + if (!field || !field.display) { + return ''; + } + const displayedValue = formattedValueToString(field.display(row[key])); + return displayedValue; +}; + +export interface FilteredRowsResult { + rows: TableRow[]; + filter: FilterType; + setFilter: React.Dispatch>; + crossFilterOrder: string[]; + crossFilterRows: Record; +} + +export interface FilteredRowsOptions { + hasNestedFrames: boolean; +} + +export function useFilteredRows( + rows: TableRow[], + fields: Field[], + { hasNestedFrames }: FilteredRowsOptions +): FilteredRowsResult { + // TODO: allow persisted filter selection via url + const [filter, setFilter] = useState({}); + const filterValues = useMemo(() => Object.entries(filter), [filter]); + + const crossFilterOrder: FilteredRowsResult['crossFilterOrder'] = useMemo( + () => Array.from(new Set(filterValues.map(([key]) => key))), + [filterValues] + ); + + const [filteredRows, crossFilterRows] = useMemo(() => { + const crossFilterRows: FilteredRowsResult['crossFilterRows'] = {}; + + const filterRows = (row: TableRow): boolean => { + for (const [key, value] of filterValues) { + const displayedValue = getDisplayedValue(row, key, fields); + if (!value.filteredSet.has(displayedValue)) { + return false; + } + // collect rows for crossFilter + crossFilterRows[key] = crossFilterRows[key] ?? []; + crossFilterRows[key].push(row); + } + return true; + }; + + const filteredRows = hasNestedFrames + ? processNestedTableRows(rows, (parents) => parents.filter(filterRows)) + : rows.filter(filterRows); + + return [filteredRows, crossFilterRows]; + }, [filterValues, rows, fields, hasNestedFrames]); + + return { + rows: filteredRows, + filter, + setFilter, + crossFilterOrder, + crossFilterRows, + }; +} + +export interface SortedRowsOptions { + columnTypes: ColumnTypes; + hasNestedFrames: boolean; + initialSortBy?: TableSortByFieldState[]; +} + +export interface SortedRowsResult { + rows: TableRow[]; + sortColumns: SortColumn[]; + setSortColumns: React.Dispatch>; +} + +export function useSortedRows( + rows: TableRow[], + fields: Field[], + { initialSortBy, columnTypes, hasNestedFrames }: SortedRowsOptions +): SortedRowsResult { + const initialSortColumns = useMemo( + () => + initialSortBy?.flatMap(({ displayName, desc }) => { + if (!fields.some((f) => getDisplayName(f) === displayName)) { + return []; + } + return [ + { + columnKey: displayName, + direction: desc ? ('DESC' as const) : ('ASC' as const), + }, + ]; + }) ?? [], + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + const [sortColumns, setSortColumns] = useState(initialSortColumns); + + const sortedRows = useMemo( + () => applySort(rows, fields, sortColumns, columnTypes, hasNestedFrames), + [rows, fields, sortColumns, hasNestedFrames, columnTypes] + ); + + return { + rows: sortedRows, + sortColumns, + setSortColumns, + }; +} + +export interface PaginatedRowsOptions { + height: number; + width: number; + rowHeight: number | ((row: TableRow) => number); + hasHeader?: boolean; + hasFooter?: boolean; + paginationHeight?: number; + enabled?: boolean; +} + +export interface PaginatedRowsResult { + rows: TableRow[]; + page: number; + setPage: React.Dispatch>; + numPages: number; + rowsPerPage: number; + pageRangeStart: number; + pageRangeEnd: number; + smallPagination: boolean; +} + +// hand-measured. pagination height is 30px, plus 8px top margin +const PAGINATION_HEIGHT = 38; + +export function usePaginatedRows( + rows: TableRow[], + { height, width, hasHeader, hasFooter, rowHeight, enabled }: PaginatedRowsOptions +): PaginatedRowsResult { + // TODO: allow persisted page selection via url + const [page, setPage] = useState(0); + const numRows = rows.length; + + // calculate average row height if row height is variable. + const avgRowHeight = useMemo(() => { + if (typeof rowHeight === 'number') { + return rowHeight; + } + return rows.reduce((avg, row, _, { length }) => avg + rowHeight(row) / length, 0); + }, [rows, rowHeight]); + + // using dimensions of the panel, calculate pagination parameters + const { numPages, rowsPerPage, pageRangeStart, pageRangeEnd, smallPagination } = useMemo((): { + numPages: number; + rowsPerPage: number; + pageRangeStart: number; + pageRangeEnd: number; + smallPagination: boolean; + } => { + if (!enabled) { + return { numPages: 0, rowsPerPage: 0, pageRangeStart: 1, pageRangeEnd: numRows, smallPagination: false }; + } + + // calculate number of rowsPerPage based on height stack + const rowAreaHeight = + height - (hasHeader ? TABLE.HEADER_ROW_HEIGHT : 0) - (hasFooter ? avgRowHeight : 0) - PAGINATION_HEIGHT; + const heightPerRow = Math.floor(rowAreaHeight / (avgRowHeight || 1)); + // ensure at least one row per page is displayed + let rowsPerPage = heightPerRow > 1 ? heightPerRow : 1; + + // calculate row range for pagination summary display + const pageRangeStart = page * rowsPerPage + 1; + let pageRangeEnd = pageRangeStart + rowsPerPage - 1; + 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, hasHeader, hasFooter, avgRowHeight, enabled, numRows, page]); + + // safeguard against page overflow on panel resize or other factors + useEffect(() => { + if (!enabled) { + return; + } + + if (page > numPages) { + // resets pagination to end + setPage(numPages - 1); + } + }, [numPages, enabled, page, setPage]); + + // apply pagination to the sorted rows + const paginatedRows = useMemo(() => { + if (!enabled) { + return rows; + } + const pageOffset = page * rowsPerPage; + return rows.slice(pageOffset, pageOffset + rowsPerPage); + }, [page, rowsPerPage, rows, enabled]); + + return { + rows: paginatedRows, + page: enabled ? page : -1, + setPage, + numPages, + rowsPerPage, + pageRangeStart, + pageRangeEnd, + smallPagination, + }; +} + +export interface FooterCalcsOptions { + enabled?: boolean; + isCountRowsSet?: boolean; + footerOptions?: TableFooterCalc; +} + +export function useFooterCalcs( + rows: TableRow[], + fields: Field[], + { enabled, footerOptions, isCountRowsSet }: FooterCalcsOptions +): string[] { + return useMemo(() => { + const footerReducers = footerOptions?.reducer; + + if (!enabled || !footerOptions || !Array.isArray(footerReducers) || !footerReducers.length) { + return []; + } + + return fields.map((field, index) => { + if (field.state?.calcs) { + delete field.state?.calcs; + } + + if (isCountRowsSet) { + return index === 0 ? `${rows.length}` : ''; + } + + if (index === 0) { + const footerCalcReducer = footerReducers[0]; + return footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : ''; + } + + if (field.type !== FieldType.number) { + return ''; + } + + // if field.display is undefined, don't throw + const displayFn = field.display; + if (!displayFn) { + return ''; + } + + // If fields array is specified, only show footer for fields included in that array + if (footerOptions.fields?.length && !footerOptions.fields?.includes(getDisplayName(field))) { + return ''; + } + + const calc = footerReducers[0]; + const value = reduceField({ + field: { + ...field, + values: rows.map((row) => row[getDisplayName(field)]), + }, + reducers: footerReducers, + })[calc]; + + return formattedValueToString(displayFn(value)); + }); + }, [fields, enabled, footerOptions, isCountRowsSet, rows]); +} + +export function useTextWraps(fields: Field[]): Record { + 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] + ); +} + +export function useTypographyCtx() { + const theme = useTheme2(); + const { ctx, font, avgCharWidth } = useMemo(() => { + const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + // set in grafana/data in createTypography.ts + const letterSpacing = 0.15; + + ctx.letterSpacing = `${letterSpacing}px`; + ctx.font = font; + const txt = + "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; + + return { + ctx, + font, + avgCharWidth, + }; + }, [theme.typography.fontSize, theme.typography.fontFamily]); + return { ctx, font, avgCharWidth }; +} + +export function useRowHeight( + columnWidths: number[], + fields: Field[], + hasNestedFrames: boolean, + defaultRowHeight: number, + expandedRows: Record +): number | ((row: TableRow) => number) { + const [wrappedColIdxs, hasWrappedCols] = useMemo(() => { + let hasWrappedCols = false; + return [ + fields.map((field) => { + if (field.type !== FieldType.string) { + return false; + } + + const cellOptions = getCellOptions(field); + const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText; + const type = cellOptions.type; + const result = !!wrapText && type !== TableCellDisplayMode.Image; + if (result === true) { + hasWrappedCols = true; + } + return result; + }), + hasWrappedCols, + ]; + }, [fields]); + + const { ctx, avgCharWidth } = useTypographyCtx(); + + const rowHeight = useMemo(() => { + // row height is only complicated when there are nested frames or wrapped columns. + if (!hasNestedFrames && !hasWrappedCols) { + return defaultRowHeight; + } + + 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) { + // if unexpanded, height === 0 + if (!expandedRows[row.__index]) { + 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; + const rowCount = row.data?.length ?? 0; + return Math.max(defaultRowHeight, defaultRowHeight * (rowCount + headerCount)); + } + + // 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]); + }; + }, [ + avgCharWidth, + columnWidths, + ctx, + defaultRowHeight, + expandedRows, + fields, + hasNestedFrames, + hasWrappedCols, + wrappedColIdxs, + ]); + + return rowHeight; +} + +/** + * react-data-grid is a little unwieldy when it comes to column resize events. + * we want to detect a few different column resize signals: + * - dragging the handle (only want to dispatch when handle is released) + * - double-clicking the handle (sets the column to the minimum width to fit content) + * `onColumnResize` dispatches events throughout a dragged resize, and `onColumnWidthsChanged` doesn't + * emit an event when double-click resizing occurs, so we have to build something custom on top of these + * behaviors in order to get everything working. + */ +interface UseColumnResizeState { + columnKey: string | undefined; + width: number; +} + +const INITIAL_COL_RESIZE_STATE = Object.freeze({ columnKey: undefined, width: 0 }) satisfies UseColumnResizeState; + +export function useColumnResize( + onColumnResize: TableColumnResizeActionCallback = () => {} +): DataGridProps['onColumnResize'] { + // these must be refs. if we used setState, we would run into race conditions with these event listeners + const colResizeState = useRef({ ...INITIAL_COL_RESIZE_STATE }); + const pointerIsDown = useRef(false); + + // to detect whether we got a double-click resize, we track whether the pointer is currently down + useLayoutEffect(() => { + function pointerDown(_event: PointerEvent) { + pointerIsDown.current = true; + } + + function pointerUp(_event: PointerEvent) { + pointerIsDown.current = false; + } + + window.addEventListener('pointerdown', pointerDown); + window.addEventListener('pointerup', pointerUp); + + return () => { + window.removeEventListener('pointerdown', pointerDown); + window.removeEventListener('pointerup', pointerUp); + }; + }); + + const dispatchEvent = useCallback(() => { + if (colResizeState.current.columnKey) { + onColumnResize(colResizeState.current.columnKey, Math.floor(colResizeState.current.width)); + colResizeState.current = { ...INITIAL_COL_RESIZE_STATE }; + } + window.removeEventListener('click', dispatchEvent, { capture: true }); + }, [onColumnResize]); + + // this is the callback that gets passed to react-data-grid + const dataGridResizeHandler = useCallback( + (column: Column, width: number) => { + if (!colResizeState.current.columnKey) { + window.addEventListener('click', dispatchEvent, { capture: true }); + } + + colResizeState.current.columnKey = column.key; + colResizeState.current.width = width; + + // when double clicking to resize, this handler will fire, but the pointer will not be down, + // meaning that we should immediately flush the new width + if (!pointerIsDown.current) { + dispatchEvent(); + } + }, + [dispatchEvent] + ); + + return dataGridResizeHandler; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 140c91f9e40..2946923db8f 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -1,4 +1,5 @@ import { Property } from 'csstype'; +import { SyntheticEvent } from 'react'; import { Column } from 'react-data-grid'; import { @@ -12,10 +13,12 @@ import { InterpolateFunction, FieldType, DataFrameWithValue, + SelectableValue, } from '@grafana/data'; -import { TableCellOptions, TableCellHeight, TableFieldOptions } from '@grafana/schema'; +import { TableCellHeight, TableFieldOptions } from '@grafana/schema'; import { TableCellInspectorMode } from '../TableCellInspector'; +import { TableCellOptions } from '../types'; export const FILTER_FOR_OPERATOR = '='; export const FILTER_OUT_OPERATOR = '!='; @@ -39,11 +42,15 @@ export type TableFieldOptionsType = Omit & { headerComponent?: React.ComponentType; }; -export type FilterType = { - [key: string]: { +export type FilterType = Record< + string, + { filteredSet: Set; - }; -}; + filtered?: Array>; + searchFilter?: string; + operator?: SelectableValue; + } +>; /* ----------------------------- Table specific types ----------------------------- */ export interface TableSummaryRow { @@ -51,12 +58,9 @@ export interface TableSummaryRow { } export interface TableColumn extends Column { - key: string; // Unique identifier used by DataGrid - name: string; // Display name in header field: Field; // Grafana field data/config width?: number | string; // Column width minWidth?: number; // Min width constraint - cellClass?: string; // CSS styling } // Possible values for table cells based on field types @@ -77,7 +81,8 @@ export interface TableRow { // Nested table properties data?: DataFrame; - 'Nested frames'?: DataFrame[]; + __nestedFrames?: DataFrame[]; + __expanded?: boolean; // For row expansion state // Generic typing for column values [columnName: string]: TableCellValue; @@ -104,7 +109,7 @@ export interface TableSortByFieldState { export interface TableFooterCalc { show: boolean; - reducer?: string[]; // Make this optional + reducer?: string[]; fields?: string[]; enablePagination?: boolean; countRows?: boolean; @@ -129,6 +134,7 @@ export interface BaseTableProps { footerValues?: FooterItem[]; enablePagination?: boolean; cellHeight?: TableCellHeight; + structureRev?: number; /** @alpha Used by SparklineCell when provided */ timeRange?: TimeRange; enableSharedCrosshair?: boolean; @@ -144,28 +150,48 @@ export interface BaseTableProps { /* ---------------------------- Table cell props ---------------------------- */ export interface TableNGProps extends BaseTableProps {} -export interface TableCellNGProps { - field: Field; - frame: DataFrame; - getActions?: GetActionsFunction; - height: number; - justifyContent: Property.JustifyContent; +export interface TableCellRendererProps { + actions?: ActionModel[]; rowIdx: number; - setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void; - setIsInspecting: (isInspecting: boolean) => void; - shouldTextOverflow: () => boolean; - theme: GrafanaTheme2; - timeRange: TimeRange; + frame: DataFrame; + timeRange?: TimeRange; value: TableCellValue; - rowBg: Function | undefined; + height: number; + // flags that are static per column + field: Field; + cellOptions: TableCellOptions; + width: number; + theme: GrafanaTheme2; + cellInspect: boolean; + showFilters: boolean; + justifyContent: Property.JustifyContent; +} + +export type ContextMenuProps = { + rowIdx?: number; + value: string; + mode?: TableCellInspectorMode.code | TableCellInspectorMode.text; + top?: number; + left?: number; +}; + +export interface TableCellActionsProps { + field: Field; + value: TableCellValue; + cellOptions: TableCellOptions; + displayName: string; + cellInspect: boolean; + showFilters: boolean; + setIsInspecting: React.Dispatch>; + setContextMenuProps: React.Dispatch>; + className?: string; onCellFilterAdded?: TableFilterActionCallback; - replaceVariables?: InterpolateFunction; } /* ------------------------- Specialized Cell Props ------------------------- */ export interface RowExpanderNGProps { height: number; - onCellExpand: () => void; + onCellExpand: (e: SyntheticEvent) => void; isExpanded?: boolean; } @@ -174,7 +200,7 @@ export interface SparklineCellProps { justifyContent: Property.JustifyContent; rowIdx: number; theme: GrafanaTheme2; - timeRange: TimeRange; + timeRange?: TimeRange; value: TableCellValue; width: number; } @@ -186,7 +212,6 @@ export interface BarGaugeCellProps extends ActionCellProps { theme: GrafanaTheme2; value: TableCellValue; width: number; - timeRange: TimeRange; } export interface ImageCellProps extends ActionCellProps { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index dccddd89bd8..74a9f39d224 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -1,10 +1,8 @@ -import { SortColumn } from 'react-data-grid'; - import { createDataFrame, createTheme, DataFrame, - DisplayProcessor, + DataFrameWithValue, DisplayValue, Field, FieldType, @@ -12,21 +10,12 @@ import { LinkModel, ValueLinkConfig, } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; -import { - BarGaugeDisplayMode, - TableCellBackgroundDisplayMode, - TableCellDisplayMode, - TableCellHeight, -} from '@grafana/schema'; +import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight } from '@grafana/schema'; -import { PanelContext } from '../../PanelChrome'; +import { TableCellDisplayMode } from '../types'; -import { mapFrameToDataGrid, myRowRenderer } from './TableNG'; -import { COLUMN, TABLE } from './constants'; -import { TableColumn } from './types'; +import { TABLE } from './constants'; import { - convertRGBAToHex, extractPixelValue, frameToRecords, getAlignmentFactor, @@ -35,490 +24,13 @@ import { getCellOptions, getComparator, getDefaultRowHeight, - getFooterItemNG, - getFooterStyles, getIsNestedTable, getTextAlign, - handleSort, - isTextCell, migrateTableDisplayModeToCellOptions, + getColumnTypes, } from './utils'; -const data = createDataFrame({ - fields: [ - { - name: 'Time', - type: FieldType.time, - values: [], - config: { - custom: { - width: undefined, // For width distribution testing - displayMode: 'auto', - }, - }, - }, - { - name: 'Value', - type: FieldType.number, - values: [], - display: ((v: unknown) => ({ - text: String(v), - numeric: v, - color: undefined, - prefix: undefined, - suffix: undefined, - })) as DisplayProcessor, - config: { - custom: { - width: 100, - displayMode: 'basic', - }, - }, - }, - { - name: 'Message', - type: FieldType.string, - values: [], - config: { - custom: { - align: 'center', - }, - }, - }, - ], - meta: { - custom: { - noHeader: false, // For header rendering tests - }, - }, -}); - -const calcsRef = { current: [] }; -const headerCellRefs = { current: {} }; -const crossFilterOrder = { current: [] }; -const crossFilterRows = { current: {} }; -const sortColumnsRef = { current: [] }; - -const mockOptions = { - ctx: null as unknown as CanvasRenderingContext2D, - textWraps: {}, - rows: [], - sortedRows: [], - setContextMenuProps: () => {}, - setFilter: () => {}, - setIsInspecting: () => {}, - data, - width: 800, - height: 600, - fieldConfig: { - defaults: { - custom: { - width: 'auto', - minWidth: COLUMN.MIN_WIDTH, - cellOptions: { - wrapText: false, - }, - }, - }, - overrides: [ - { - matcher: { id: 'byName', options: 'Value' }, - properties: [{ id: 'width', value: 100 }], - }, - ], - }, - columnTypes: {}, - columnWidth: 'auto', - defaultLineHeight: 40, - defaultRowHeight: 40, - expandedRows: [], - filter: {}, - headerCellRefs, - crossFilterOrder, - crossFilterRows, - isCountRowsSet: false, - styles: { cell: '', cellWrapped: '', dataGrid: '' }, - theme: createTheme(), - setSortColumns: () => {}, - sortColumnsRef, - textWrap: false, -}; - describe('TableNG utils', () => { - describe('mapFrameToDataGrid', () => { - it('take data frame and return array of columns', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Test column structure - expect(columns).toHaveLength(3); - - // Test Time column - expect(columns[0]).toMatchObject({ - key: 'Time', - name: 'Time', - field: expect.objectContaining({ - name: 'Time', - type: FieldType.time, - }), - }); - - // Test Value column with custom width - expect(columns[1]).toMatchObject({ - key: 'Value', - name: 'Value', - // TODO: fix this - // width: 100, - field: expect.objectContaining({ - name: 'Value', - type: FieldType.number, - }), - }); - - // Test Message column alignment - expect(columns[2]).toMatchObject({ - key: 'Message', - name: 'Message', - field: expect.objectContaining({ - name: 'Message', - type: FieldType.string, - config: expect.objectContaining({ - custom: expect.objectContaining({ - align: 'center', - }), - }), - }), - }); - }); - }); - - describe('column building', () => { - it('should build basic column structure', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - expect(columns).toHaveLength(3); - columns.forEach((column: TableColumn) => { - expect(column).toHaveProperty('key'); - expect(column).toHaveProperty('name'); - expect(column).toHaveProperty('field'); - expect(column).toHaveProperty('cellClass'); - expect(column).toHaveProperty('renderCell'); - expect(column).toHaveProperty('renderHeaderCell'); - }); - }); - - it.skip('should handle column width configurations', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Default width - expect(columns[0].width).toBe(350); - // Explicit width from field config - expect(columns[1].width).toBe(100); - // Default width with min width - expect(columns[2].minWidth).toBe(COLUMN.MIN_WIDTH); - }); - - it('should handle cell alignment', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - const messageColumn = columns[2]; - expect(messageColumn.field.config.custom.align).toBe('center'); - }); - - it('should handle footer/summary rows', () => { - const options = { - ...mockOptions, - isCountRowsSet: true, - }; - - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef: { current: ['3', '', ''] }, - options, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // First column should show count - const firstCell = columns[0].renderSummaryCell?.({ - row: { __depth: 0, __index: 0 }, - column: { - ...columns[0], - frozen: false, - idx: 0, - parent: undefined, - level: 0, - sortable: true, - minWidth: 100, - draggable: true, - renderCell: () => null, - renderHeaderCell: () => null, - resizable: true, - width: 100, - maxWidth: undefined, - headerCellClass: undefined, - summaryCellClass: undefined, - }, - tabIndex: 0, - }); - - expect(firstCell).toBeDefined(); - - // Check the div structure and content - const divElement = firstCell as JSX.Element; - expect(divElement.props.style).toEqual({ display: 'flex', justifyContent: 'space-between' }); - - // Check that we have two spans with correct content - const [countSpan, valueSpan] = divElement.props.children; - expect(countSpan.type).toBe('span'); - expect(countSpan.props.children.type).toBe(Trans); - expect(countSpan.props.children.props.i18nKey).toBe('grafana-ui.table.count'); - expect(valueSpan.props.children).toBe('3'); - }); - }); - - describe('nested frames', () => { - const nestedData = createDataFrame({ - fields: [ - { name: 'Time', type: FieldType.time, values: [1, 2] }, - { name: 'Value', type: FieldType.number, values: [10, 20] }, - { - name: 'Nested frames', - type: FieldType.nestedFrames, - values: [ - [ - createDataFrame({ - fields: [ - { name: 'Nested Time', type: FieldType.time, values: [3] }, - { name: 'Nested Value', type: FieldType.number, values: [30] }, - ], - }), - ], - ], - }, - ], - }); - - it('should add expander column for nested frames', () => { - const columns = mapFrameToDataGrid({ - frame: nestedData, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // First column should be expander - expect(columns[0]).toMatchObject({ - key: 'expanded', - name: '', - width: COLUMN.EXPANDER_WIDTH, - minWidth: COLUMN.EXPANDER_WIDTH, - }); - }); - - it('should not render nested frame type fields', () => { - const columns = mapFrameToDataGrid({ - frame: nestedData, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Should only have expander + Time + Value (not Nested frames column) - expect(columns).toHaveLength(3); - - // No column should be of type nestedFrames - const hasNestedFrameColumn = columns.some((col: TableColumn) => col.field.type === FieldType.nestedFrames); - expect(hasNestedFrameColumn).toBe(false); - }); - - it('should render nested frame data when expanded', () => { - const expandedRows = [0]; - const columns = mapFrameToDataGrid({ - frame: nestedData, - calcsRef, - options: { ...mockOptions, expandedRows }, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Get the rendered content of first row's expander cell - const expanderCell = columns[0].renderCell?.({ - row: { - __depth: 1, - __index: 0, - data: nestedData.fields[2].values[0][0], - }, - rowIdx: 0, - column: { - ...columns[0], - frozen: false, - idx: 0, - parent: undefined, - level: 0, - sortable: true, - minWidth: 100, - draggable: true, - renderCell: () => null, - renderHeaderCell: () => null, - resizable: true, - width: 100, - maxWidth: undefined, - headerCellClass: undefined, - summaryCellClass: undefined, - }, - isCellEditable: false, - tabIndex: 0, - onRowChange: () => {}, - }); - - expect(expanderCell).toBeDefined(); - }); - }); - - describe('getFooterItemNG', () => { - const rows = [ - { Field1: 1, Text: 'a', __depth: 0, __index: 0 }, - { Field1: 2, Text: 'b', __depth: 0, __index: 1 }, - { Field1: 3, Text: 'c', __depth: 0, __index: 2 }, - { Field2: 3, Text: 'd', __depth: 0, __index: 3 }, - { Field2: 10, Text: 'e', __depth: 0, __index: 4 }, - ]; - - const numericField: Field = { - name: 'Field1', - type: FieldType.number, - values: [1, 2, 3], - config: { - custom: {}, - }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: undefined, - }; - - const numericField2: Field = { - name: 'Field2', - type: FieldType.number, - values: [3, 10], - config: { custom: {} }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: undefined, - }; - - const textField: Field = { - name: 'Text', - type: FieldType.string, - values: ['a', 'b', 'c'], - config: { custom: {} }, - display: (value: unknown) => ({ - text: String(value), - numeric: 0, - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: undefined, - }; - - it('should calculate sum for numeric fields', () => { - const result = getFooterItemNG(rows, numericField, { - show: true, - reducer: ['sum'], - }); - - expect(result).toBe('6'); // 1 + 2 + 3 - }); - - it('should calculate mean for numeric fields', () => { - const result = getFooterItemNG(rows, numericField, { - show: true, - reducer: ['mean'], - }); - - expect(result).toBe('2'); // (1 + 2 + 3) / 3 - }); - - it('should return empty string for non-numeric fields', () => { - const result = getFooterItemNG(rows, textField, { - show: true, - reducer: ['sum'], - }); - - expect(result).toBe(''); - }); - - it('should return empty string when footer not shown', () => { - const result = getFooterItemNG(rows, numericField, undefined); - - expect(result).toBe(''); - }); - - it('should return empty string when reducer is undefined', () => { - const result = getFooterItemNG(rows, numericField, { - show: true, - reducer: undefined, - }); - expect(result).toBe(''); - }); - - it('should correctly calculate sum for numeric fields based on selected fields', () => { - const numericField1Result = getFooterItemNG(rows, numericField, { - show: true, - reducer: ['sum'], - fields: ['Field1'], - }); - - const numericField2Result = getFooterItemNG(rows, numericField2, { - show: true, - reducer: ['sum'], - fields: ['Field2'], - }); - - expect(numericField1Result).toBe('6'); // 1 + 2 + 3 - expect(numericField2Result).toBe('13'); // 3 + 10 - }); - }); - describe('text alignment', () => { it('should map alignment options to flex values', () => { // Test 'left' alignment @@ -717,56 +229,6 @@ describe('TableNG utils', () => { }); }); - describe('handleSort', () => { - const setSortColumns = jest.fn(); - const sortColumnsRef: { current: SortColumn[] } = { current: [] }; - - beforeEach(() => { - setSortColumns.mockClear(); - sortColumnsRef.current = []; - }); - - it('should set initial sort', () => { - handleSort('Value', 'ASC', false, setSortColumns, sortColumnsRef); - - expect(setSortColumns).toHaveBeenCalledWith([{ columnKey: 'Value', direction: 'ASC' }]); - }); - - it('should toggle sort direction on same column', () => { - // Initial sort - sortColumnsRef.current = [{ columnKey: 'Value', direction: 'ASC' }] as const; - - handleSort('Value', 'DESC', false, setSortColumns, sortColumnsRef); - - expect(setSortColumns).toHaveBeenCalledWith([{ columnKey: 'Value', direction: 'DESC' }]); - }); - - it('should handle multi-sort with shift key', () => { - // Initial sort - sortColumnsRef.current = [{ columnKey: 'Time', direction: 'ASC' }] as const; - - handleSort('Value', 'ASC', true, setSortColumns, sortColumnsRef); - - expect(setSortColumns).toHaveBeenCalledWith([ - { columnKey: 'Time', direction: 'ASC' }, - { columnKey: 'Value', direction: 'ASC' }, - ]); - }); - - it('should remove sort when toggling through all states', () => { - // Initial ASC sort - sortColumnsRef.current = [{ columnKey: 'Value', direction: 'ASC' }] as const; - - // Toggle to DESC - handleSort('Value', 'DESC', false, setSortColumns, sortColumnsRef); - expect(setSortColumns).toHaveBeenCalledWith([{ columnKey: 'Value', direction: 'DESC' }]); - - // Toggle to no sort - handleSort('Value', 'DESC', false, setSortColumns, sortColumnsRef); - expect(setSortColumns).toHaveBeenCalledWith([]); - }); - }); - describe('getAlignmentFactor', () => { it('should create a new alignment factor when none exists', () => { // Create a field with no existing alignment factor @@ -940,6 +402,102 @@ describe('TableNG utils', () => { }) ); }); + + it.todo('alignmentFactor.text = displayValue.text;'); + }); + + describe('getColumnTypes', () => { + it('builds the expected record with column types', () => { + const fields: Field[] = [ + { + name: 'name', + type: FieldType.string, + display: (v) => ({ text: v as string, numeric: NaN }), + config: {}, + values: [], + }, + { + name: 'age', + type: FieldType.number, + display: (v) => ({ text: (v as number).toString(), numeric: v as number }), + config: {}, + values: [], + }, + { + name: 'active', + type: FieldType.boolean, + display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }), + config: {}, + values: [], + }, + ]; + const result = getColumnTypes(fields); + + expect(result).toEqual({ + name: FieldType.string, + age: FieldType.number, + active: FieldType.boolean, + }); + }); + + it('should recursively build column types when nested fields are present', () => { + const frame: DataFrame = { + fields: [ + { type: FieldType.string, name: 'stringCol', config: {}, values: [] }, + { + type: FieldType.nestedFrames, + name: 'nestedCol', + config: {}, + values: [ + [ + createDataFrame({ + fields: [ + { name: 'time', values: [1, 2] }, + { name: 'value', values: [10, 20] }, + ], + }), + ], + [ + createDataFrame({ + fields: [ + { name: 'time', values: [3, 4] }, + { name: 'value', values: [30, 40] }, + ], + }), + ], + ], + }, + ], + length: 0, + name: 'test', + }; + + expect(getColumnTypes(frame.fields)).toEqual({ + stringCol: FieldType.string, + time: FieldType.time, + value: FieldType.number, + }); + }); + + it('does not throw if nestedFrames has no values', () => { + const frame: DataFrame = { + fields: [ + { type: FieldType.string, name: 'stringCol', config: {}, values: [] }, + { + type: FieldType.nestedFrames, + name: 'nestedCol', + config: {}, + values: [], + }, + ], + length: 0, + name: 'test', + }; + + expect(getColumnTypes(frame.fields)).toEqual({ + stringCol: FieldType.string, + }); + }); }); describe('getIsNestedTable', () => { @@ -952,7 +510,7 @@ describe('TableNG utils', () => { length: 0, name: 'test', }; - expect(getIsNestedTable(frame)).toBe(true); + expect(getIsNestedTable(frame.fields)).toBe(true); }); it('should return false for regular frames', () => { @@ -964,7 +522,7 @@ describe('TableNG utils', () => { length: 0, name: 'test', }; - expect(getIsNestedTable(frame)).toBe(false); + expect(getIsNestedTable(frame.fields)).toBe(false); }); }); @@ -1006,241 +564,28 @@ describe('TableNG utils', () => { expect(comparator(true, false)).toBeGreaterThan(0); expect(comparator(true, true)).toBe(0); }); - }); - /* - describe('shouldTextOverflow', () => { - const mockContext = { - font: '', - measureText: (text: string) => ({ - // Each character is 8px wide in our mock context - width: text.length * 8, - }), - }; - const ctx = mockContext as unknown as CanvasRenderingContext2D; + it('should compare frame values', () => { + const comparator = getComparator(FieldType.frame); - const headerCellRefs = { - current: { - column1: { - getBoundingClientRect: () => ({ width: 100 }), - offsetWidth: 100, - }, - } as unknown as Record, - }; - - it('should return true when text exceeds cell width', () => { - const row = { - __depth: 0, - __index: 0, - // 43*8 = 344px wide cell, it should overflow as it's greater than 100px - column1: 'This is a very long text that should overflow', + // simulate using `first`. + const frame1: DataFrameWithValue = { + value: 1, + ...createDataFrame({ fields: [{ name: 'a', values: [1, 2, 3, 4] }] }), }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - false, // textWrap - { - config: { - custom: { - inspect: false, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(true); - }); - - it('should return false when text fits cell width', () => { - const row = { - __depth: 0, - __index: 0, - // 9*8 = 72px wide cell, it should fit as it's less than 100px - column1: 'Short text', + const frame2: DataFrameWithValue = { + value: 4, + ...createDataFrame({ fields: [{ name: 'a', values: [4, 3, 2, 1] }] }), }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - false, // textWrap - { - config: { - custom: { - inspect: false, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(false); - }); - - it('should return false when text wrapping is enabled', () => { - const row = { - __depth: 0, - __index: 0, - column1: 'This is a very long text that should wrap instead of overflow', - }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - true, // textWrap ENABLED - { - config: { - custom: { - inspect: true, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(false); - }); - - it('should return false when cell inspection is enabled', () => { - const row = { - __depth: 0, - __index: 0, - column1: 'This is a very long text', - }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - false, // textWrap - { - config: { - custom: { - inspect: true, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(false); - }); - }); - - describe.skip('getRowHeight', () => { - const ctx = { - font: '14px Inter, sans-serif', - letterSpacing: '0.15px', - measureText: (text: string) => ({ - width: text.length * 8, - }), - } as unknown as CanvasRenderingContext2D; - - const calc = uWrap(ctx); - - const headerCellRefs = { - current: { - stringCol: { offsetWidth: 100 }, - numberCol: { offsetWidth: 100 }, - } as unknown as Record, - }; - - it('should return default height when no text cells present', () => { - const row = { - __depth: 0, - __index: 0, - numberCol: 123, - }; - const columnTypes = { numberCol: FieldType.number }; - - const height = getRowHeight( - row, - calc, - 8, - headerCellRefs, - 20, // lineHeight - 40, // defaultRowHeight - 8 // padding - ); - - expect(height).toBe(40); - }); - - it('should calculate height based on longest text cell', () => { - const row = { - __depth: 0, - __index: 0, - stringCol: 'This is a very long text that should wrap', - numberCol: 123, - }; - const columnTypes = { - stringCol: FieldType.string, - numberCol: FieldType.number, + const frame3: DataFrameWithValue = { + value: 4, + ...createDataFrame({ fields: [{ name: 'a', values: [4, 5, 6, 7] }] }), }; - const height = getRowHeight(row, columnTypes, headerCellRefs, ctx, 20, 40, 8); - - expect(height).toBeGreaterThan(40); - expect(height).toBe(112); - }); - - it('should handle empty header cell refs', () => { - const row = { - __depth: 0, - __index: 0, - stringCol: 'Some text', - }; - const columnTypes = { stringCol: FieldType.string }; - const emptyRefs = { current: {} } as unknown as React.MutableRefObject>; - - const height = getRowHeight(row, columnTypes, emptyRefs, ctx, 20, 40, 8); - - expect(height).toBe(40); - }); - }); -*/ - - describe('isTextCell', () => { - it('should return true for string fields', () => { - expect(isTextCell('column', { column: FieldType.string })).toBe(true); - }); - - it('should return false for non-string fields', () => { - expect(isTextCell('column', { column: FieldType.number })).toBe(false); - expect(isTextCell('column', { column: FieldType.time })).toBe(false); - expect(isTextCell('column', { column: FieldType.boolean })).toBe(false); - }); - - it('should handle unknown fields', () => { - expect(isTextCell('unknown', { column: FieldType.string })).toBe(false); + expect(comparator(frame1, frame2)).toBeLessThan(0); + expect(comparator(frame2, frame1)).toBeGreaterThan(0); + expect(comparator(frame2, frame2)).toBe(0); + expect(comparator(frame2, frame3)).toBe(0); // equivalent start values }); }); @@ -1500,133 +845,81 @@ describe('TableNG utils', () => { expect(links?.find((link) => link.onClick !== undefined)).toBeDefined(); expect(links?.find((link) => link.href === 'http://example.com/full')).toBeDefined(); }); - }); - /* - describe.skip('getCellHeight', () => { - // Create a mock CanvasRenderingContext2D - const createMockContext = () => { - return { - measureText: jest.fn((text) => { - // Simple mock that returns width based on text length - // This is a simplification - real browser would be more complex - return { width: text.length * 8 }; // Assume 8px per character - }), - } as unknown as CanvasRenderingContext2D; - }; + it('should bind the onClick handlers', () => { + const onClickHandler = jest.fn(); + // Create links with different valid configurations + const mockLinks: LinkModel[] = [ + // Internal link with onClick handler + { + title: 'Internal Link', + href: '', // Empty href for internal links + onClick: onClickHandler, + target: '_self', + origin: { datasourceUid: 'test' }, + }, + ]; - it('should return default row height when ctx is null', () => { - const defaultRowHeight = 40; - const height = getCellHeight('Some text', 100, null, 20, defaultRowHeight); - expect(height).toBe(defaultRowHeight); + const field: Field = { + name: 'test', + type: FieldType.string, + config: {}, + values: ['value1'], + getLinks: () => mockLinks, + }; + + const links = getCellLinks(field, 0); + + const link = links?.[0]; + const event = new MouseEvent('click', { bubbles: true }); + jest.spyOn(event, 'preventDefault'); + + link?.onClick?.(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(onClickHandler).toHaveBeenCalledWith(event, { field, rowIndex: 0 }); }); - it('should return default row height for text that fits in one line', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - const cellWidth = 100; // 100px width - const text = 'Short'; // 5 chars * 8px = 40px, fits in cellWidth + it.each([ + { keyName: 'metaKey', eventOverride: { metaKey: true } }, + { keyName: 'ctrlKey', eventOverride: { ctrlKey: true } }, + { keyName: 'shiftKey', eventOverride: { shiftKey: true } }, + ])( + 'should allow open a link in a new tab when $keyName clicked instead of using the handler', + ({ eventOverride }) => { + const onClickHandler = jest.fn(); + // Create links with different valid configurations + const mockLinks: LinkModel[] = [ + // Internal link with onClick handler + { + title: 'Internal Link', + href: '', // Empty href for internal links + onClick: onClickHandler, + target: '_self', + origin: { datasourceUid: 'test' }, + }, + ]; - const height = getCellHeight(text, cellWidth, mockContext, 20, defaultRowHeight); + const field: Field = { + name: 'test', + type: FieldType.string, + config: {}, + values: ['value1'], + getLinks: () => mockLinks, + }; - // Since text fits in one line, should return default height - expect(height).toBe(defaultRowHeight); - expect(mockContext.measureText).toHaveBeenCalled(); - }); + const links = getCellLinks(field, 0); - it('should calculate height for text that wraps to multiple lines', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - const lineHeight = 20; - const cellWidth = 100; // 100px width - // This text is long enough to wrap to multiple lines - const text = 'This is a very long text that will definitely need to wrap to multiple lines in our table cell'; + const link = links?.[0]; + const event = new MouseEvent('click', { bubbles: true, ...eventOverride }); + jest.spyOn(event, 'preventDefault'); - const height = getCellHeight(text, cellWidth, mockContext, lineHeight, defaultRowHeight); + link?.onClick?.(event); - // Should be greater than default height since text wraps - expect(height).toBeGreaterThan(defaultRowHeight); - expect(height).toBe(180); - // Height should be a multiple of line height plus padding - expect(height % lineHeight).toBe(0); - expect(mockContext.measureText).toHaveBeenCalled(); - }); - - it('should account for padding when calculating height', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - const lineHeight = 20; - const cellWidth = 100; - const padding = 10; - const text = 'This is a very long text that will wrap to multiple lines'; - - const heightWithoutPadding = getCellHeight(text, cellWidth, mockContext, lineHeight, defaultRowHeight); - const heightWithPadding = getCellHeight(text, cellWidth, mockContext, lineHeight, defaultRowHeight, padding); - - // Height with padding should be greater than without padding - expect(heightWithPadding).toBeGreaterThan(heightWithoutPadding); - // The difference should be related to the padding (padding is applied twice in the function) - expect(heightWithPadding - heightWithoutPadding).toBe(padding * 2 * 2); - }); - - it('should handle empty text', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - - const height = getCellHeight('', 100, mockContext, 20, defaultRowHeight); - - // Empty text should return default height - expect(height).toBe(defaultRowHeight); - }); - }); -*/ - - describe('getFooterStyles', () => { - it('should create an emotion css class', () => { - const styles = getFooterStyles('flex-start'); - - // Check that the footerCell style has been created - expect(styles.footerCell).toBeDefined(); - - // Get the CSS string representation - const cssString = styles.footerCell.toString(); - - // Verify it's an Emotion CSS class - expect(cssString).toContain('css-'); - }); - - it('should use the provided justification value', () => { - const styles = getFooterStyles('center'); - - // Create a DOM element and apply the CSS class - document.body.innerHTML = `
Test
`; - const element = document.querySelector('div'); - - // Get the computed style - const computedStyle = window.getComputedStyle(element!); - - // Check the CSS property - expect(computedStyle.justifyContent).toBe('center'); - }); - - it('should default to space-between when no justification is provided', () => { - const styles = getFooterStyles(undefined as any); - - // Create a DOM element and apply the CSS class - document.body.innerHTML = `
Test
`; - const element = document.querySelector('div'); - - // Get the computed style - const computedStyle = window.getComputedStyle(element!); - - // Check the CSS property - expect(computedStyle.justifyContent).toBe('space-between'); - }); - - // Clean up after all tests - afterAll(() => { - document.body.innerHTML = ''; - }); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onClickHandler).not.toHaveBeenCalled(); + } + ); }); describe('extractPixelValue', () => { @@ -1681,12 +974,9 @@ describe('TableNG utils', () => { }); }); - describe('convertRGBAToHex', () => { - it('should convert RGBA format to hex with alpha', () => { - expect(convertRGBAToHex('#181b1f', 'rgba(255, 0, 0, 1)')).toBe('#ff0000'); - expect(convertRGBAToHex('#181b1f', 'rgba(0, 255, 0, 0.5)')).toBe('#0c8d10'); - expect(convertRGBAToHex('#181b1f', 'rgba(0, 0, 255, 0)')).toBe('#181b1f'); - }); + describe('getCellHeightCalculator', () => { + it.todo('returns a cell height calculator'); + it.todo('returns a minimum height of the default row height'); }); describe('getDefaultRowHeight', () => { @@ -1716,110 +1006,4 @@ describe('TableNG utils', () => { expect(result).toBe(expected); }); }); - - describe('myRowRenderer', () => { - // Create mock props for testing - const createMockProps = (depth: number, hasData: boolean, index: number) => { - return { - row: { - __depth: depth, - __index: index, - data: hasData ? { length: 2 } : undefined, - }, - viewportColumns: [], - rowIdx: 0, - isRowSelected: false, - onRowClick: jest.fn(), - onRowDoubleClick: jest.fn(), - rowClass: '', - top: 0, - height: 40, - 'aria-rowindex': 1, - 'aria-selected': false, - gridRowStart: 1, - isLastRow: false, - selectedCellIdx: undefined, - selectCell: jest.fn(), - lastFrozenColumnIndex: -1, - copiedCellIdx: undefined, - draggedOverCellIdx: undefined, - setDraggedOverRowIdx: jest.fn(), - onRowChange: jest.fn(), - rowArray: [], - selectedPosition: { idx: 0, rowIdx: 0, mode: 'SELECT' }, - } as any; - }; - - const mockPanelContext = { - id: 1, - title: 'Test Panel', - description: 'Test Description', - width: 800, - height: 600, - timeRange: { from: 'now-6h', to: 'now' }, - timeZone: 'browser', - onTimeRangeChange: jest.fn(), - onOptionsChange: jest.fn(), - onFieldConfigChange: jest.fn(), - onInstanceStateChange: jest.fn(), - replaceVariables: jest.fn(), - eventBus: { - publish: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - }, - } as unknown as PanelContext; - - const mockData = createDataFrame({ - fields: [ - { name: 'Time', type: FieldType.time, values: [] }, - { name: 'Value', type: FieldType.number, values: [] }, - ], - }); - - it('returns null for non-expanded child rows', () => { - const props = createMockProps(1, false, 0); - const expandedRows: number[] = []; // No expanded rows - - const view = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false); - - expect(view).toBeNull(); - }); - - it('renders child rows when parent is expanded', () => { - const props = createMockProps(1, false, 0); - const expandedRows: number[] = [0]; // Row 0 is expanded - - const view = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false); - - expect(view).not.toBeNull(); - }); - - it('adds aria-expanded attribute to parent rows with nested data', () => { - const props = createMockProps(0, true, 0); - const expandedRows: number[] = [0]; // Row 0 is expanded - - const result = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false) as JSX.Element; - - expect(result.props['aria-expanded']).toBe(true); - }); - - it('sets aria-expanded to false when parent row is not expanded', () => { - const props = createMockProps(0, true, 0); - const expandedRows: number[] = []; // No expanded rows - - const result = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false) as JSX.Element; - - expect(result.props['aria-expanded']).toBe(false); - }); - - it('renders regular rows without aria-expanded attribute', () => { - const props = createMockProps(0, false, 0); - const expandedRows: number[] = []; - - const result = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false) as JSX.Element; - - expect(result.props['aria-expanded']).toBeUndefined(); - }); - }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 3827d2adf34..d65b1cd9ff5 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1,7 +1,5 @@ -import { css } from '@emotion/css'; import { Property } from 'csstype'; -import React from 'react'; -import { SortColumn, SortDirection } from 'react-data-grid'; +import { SortColumn } from 'react-data-grid'; import tinycolor from 'tinycolor2'; import { varPreLine } from 'uwrap'; @@ -9,7 +7,6 @@ import { FieldType, Field, formattedValueToString, - reduceField, GrafanaTheme2, DisplayValue, LinkModel, @@ -18,89 +15,24 @@ import { } from '@grafana/data'; import { BarGaugeDisplayMode, - TableAutoCellOptions, TableCellBackgroundDisplayMode, TableCellDisplayMode, TableCellHeight, - TableCellOptions, - TableSortByFieldState, } from '@grafana/schema'; import { getTextColorForAlphaBackground } from '../../../utils/colors'; -import { TableCellInspectorMode } from '../TableCellInspector'; +import { TableCellOptions } from '../types'; -import { TABLE } from './constants'; -import { - CellColors, - TableRow, - TableFieldOptionsType, - ColumnTypes, - FilterType, - FrameToRowsConverter, - TableNGProps, - Comparator, - TableFooterCalc, -} from './types'; +import { COLUMN, TABLE } from './constants'; +import { CellColors, TableRow, TableFieldOptionsType, ColumnTypes, FrameToRowsConverter, Comparator } from './types'; /* ---------------------------- Cell calculations --------------------------- */ -export function getCellHeight( - text: string, - cellWidth: number, // width of the cell without padding - ctx: CanvasRenderingContext2D, - lineHeight: number, - defaultRowHeight: number, - padding = 0 -) { - const PADDING = padding * 2; - - if (typeof text === 'string') { - const words = text.split(/\s/); - const lines = []; - let currentLine = ''; - - // Let's just wrap the lines and see how well the measurement works - for (let i = 0; i < words.length; i++) { - const currentWord = words[i]; - // TODO: this method is not accurate - let lineWidth = ctx.measureText(currentLine + ' ' + currentWord).width; - - // if line width is less than the cell width, add the word to the current line and continue - // else add the current line to the lines array and start a new line with the current word - if (lineWidth < cellWidth) { - currentLine += ' ' + currentWord; - } else { - lines.push({ - width: lineWidth, - line: currentLine, - }); - - currentLine = currentWord; - } - - // if we are at the last word, add the current line to the lines array - if (i === words.length - 1) { - lines.push({ - width: lineWidth, - line: currentLine, - }); - } - } - - if (lines.length === 1) { - return defaultRowHeight; - } - - // TODO: double padding to adjust osContext.measureText() results - const height = lines.length * lineHeight + PADDING * 2; - - return height; - } - - return defaultRowHeight; -} - 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, @@ -111,15 +43,17 @@ export function getCellHeightCalculator( const { count } = varPreLine(ctx); return (text: string, cellWidth: number) => { - const effectiveCellWidth = Math.max(cellWidth, 20); // Minimum width to work with - const TOTAL_PADDING = padding * 2; - const numLines = count(text, effectiveCellWidth); - const totalHeight = numLines * lineHeight + TOTAL_PADDING; + const numLines = count(text, cellWidth); + const totalHeight = numLines * lineHeight + 2 * padding; return Math.max(totalHeight, defaultRowHeight); }; } -export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellHeight | undefined): number { +/** + * @internal + * Returns the default row height based on the theme and cell height setting. + */ +export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCellHeight): number { const bodyFontSize = theme.typography.fontSize; const lineHeight = theme.typography.body.lineHeight; @@ -136,61 +70,12 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellH } /** - * getRowHeight determines cell height based on cell width + text length. Used - * for when textWrap is enabled. + * @internal + * Returns true if text overflow handling should be applied to the cell. */ -export function getRowHeight( - row: TableRow, - calc: CellHeightCalculator, - avgCharWidth: number, - defaultRowHeight: number, - fieldsData: { - headersLength: number; - textWraps: { [key: string]: boolean }; - columnTypes: ColumnTypes; - columnWidths: Record; - fieldDisplayType: Record; - } -): number { - let maxLines = 1; - let maxLinesCol = ''; - - for (const key in row) { - if ( - fieldsData.columnTypes[key] === FieldType.string && - fieldsData.textWraps[key] && - fieldsData.fieldDisplayType[key] !== TableCellDisplayMode.Image - ) { - const cellText = row[key] as string; - - if (cellText != null) { - const charsPerLine = fieldsData.columnWidths[key] / avgCharWidth; - const approxLines = cellText.length / charsPerLine; - - if (approxLines > maxLines) { - maxLines = approxLines; - maxLinesCol = key; - } - } - } - } - - return maxLinesCol === '' ? defaultRowHeight : calc(row[maxLinesCol] as string, fieldsData.columnWidths[maxLinesCol]); -} - -export function isTextCell(key: string, columnTypes: Record): boolean { - return columnTypes[key] === FieldType.string; -} - export function shouldTextOverflow( key: string, - row: TableRow, columnTypes: ColumnTypes, - headerCellRefs: React.MutableRefObject>, - ctx: CanvasRenderingContext2D, - lineHeight: number, - defaultRowHeight: number, - padding: number, textWrap: boolean, field: Field, cellType: TableCellDisplayMode @@ -199,13 +84,17 @@ export function shouldTextOverflow( // 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 - if (textWrap || cellInspect || cellType === TableCellDisplayMode.Image || !isTextCell(key, columnTypes)) { + if (textWrap || cellInspect || cellType === TableCellDisplayMode.Image || columnTypes[key] !== FieldType.string) { return false; } return true; } +/** + * @internal + * Returns the text alignment for a field based on its type and configuration. + */ export function getTextAlign(field?: Field): Property.JustifyContent { if (!field) { return 'flex-start'; @@ -231,21 +120,22 @@ export function getTextAlign(field?: Field): Property.JustifyContent { return 'flex-start'; } -const defaultCellOptions: TableAutoCellOptions = { type: TableCellDisplayMode.Auto }; +const DEFAULT_CELL_OPTIONS = { type: TableCellDisplayMode.Auto } as const; +/** + * @internal + * Returns the cell options for a field, migrating from legacy displayMode if necessary. + */ export function getCellOptions(field: Field): TableCellOptions { if (field.config.custom?.displayMode) { return migrateTableDisplayModeToCellOptions(field.config.custom?.displayMode); } - if (!field.config.custom?.cellOptions) { - return defaultCellOptions; - } - - return field.config.custom.cellOptions; + return field.config.custom?.cellOptions ?? DEFAULT_CELL_OPTIONS; } /** + * @internal * Getting gauge or sparkline values to align is very tricky without looking at all values and passing them through display processor. * For very large tables that could pretty expensive. So this is kind of a compromise. We look at the first 1000 rows and cache the longest value. * If we have a cached value we just check if the current value is longer and update the alignmentFactor. This can obviously still lead to @@ -271,7 +161,7 @@ export function getAlignmentFactor( const maxIndex = Math.min(field.values.length, rowIndex + 1000); for (let i = rowIndex + 1; i < maxIndex; i++) { - const nextDisplayValue = field.display!(field.values[i]); + const nextDisplayValue = field.display?.(field.values[i]) ?? field.values[i]; if (formattedValueToString(alignmentFactor).length > formattedValueToString(nextDisplayValue).length) { alignmentFactor.text = displayValue.text; } @@ -287,69 +177,27 @@ export function getAlignmentFactor( } } -/* ------------------------------ Footer calculations ------------------------------ */ -export function getFooterItemNG(rows: TableRow[], field: Field, options: TableFooterCalc | undefined): string { - if (options === undefined) { - return ''; - } - - if (field.type !== FieldType.number) { - return ''; - } - - // Check if reducer array exists and has at least one element - if (!options.reducer || !options.reducer.length) { - return ''; - } - - // If fields array is specified, only show footer for fields included in that array - if (options.fields && options.fields.length > 0) { - if (!options.fields.includes(field.name)) { - return ''; - } - } - - const calc = options.reducer[0]; - const value = reduceField({ - field: { - ...field, - values: rows.map((row) => row[getDisplayName(field)]), - }, - reducers: options.reducer, - })[calc]; - - const formattedValue = formattedValueToString(field.display!(value)); - - return formattedValue; -} - -export const getFooterStyles = (justifyContent: Property.JustifyContent) => ({ - footerCell: css({ - display: 'flex', - justifyContent: justifyContent || 'space-between', - }), -}); - /* ------------------------- Cell color calculation ------------------------- */ const CELL_COLOR_DARKENING_MULTIPLIER = 10; const CELL_GRADIENT_DARKENING_MULTIPLIER = 15; const CELL_GRADIENT_HUE_ROTATION_DEGREES = 5; +/** + * @internal + * Returns the text and background colors for a table cell based on its options and display value. + */ export function getCellColors( theme: GrafanaTheme2, cellOptions: TableCellOptions, displayValue: DisplayValue ): CellColors { - // Convert RGBA hover color to hex to prevent transparency issues on cell hover - const autoCellBackgroundHoverColor = convertRGBAToHex(theme.colors.background.primary, theme.colors.action.hover); - // How much to darken elements depends upon if we're in dark mode const darkeningFactor = theme.isDark ? 1 : -0.7; // Setup color variables let textColor: string | undefined = undefined; let bgColor: string | undefined = undefined; - let bgHoverColor: string = autoCellBackgroundHoverColor; + let bgHoverColor: string | undefined = undefined; if (cellOptions.type === TableCellDisplayMode.ColorText) { textColor = displayValue.color; @@ -378,18 +226,14 @@ export function getCellColors( return { textColor, bgColor, bgHoverColor }; } -/** Extracts numeric pixel value from theme spacing */ +/** + * @internal + * Extracts numeric pixel value from theme spacing + */ export const extractPixelValue = (spacing: string | number): number => { return typeof spacing === 'number' ? spacing : parseFloat(spacing) || 0; }; -/** Converts an RGBA color to hex by blending it with a background color */ -export const convertRGBAToHex = (backgroundColor: string, rgbaColor: string): string => { - const bg = tinycolor(backgroundColor); - const rgba = tinycolor(rgbaColor); - return tinycolor.mix(bg, rgba, rgba.getAlpha() * 100).toHexString(); -}; - /* ------------------------------- Data links ------------------------------- */ /** * @internal @@ -410,7 +254,7 @@ export const getCellLinks = (field: Field, rowIdx: number) => { if (links[i].onClick) { const origOnClick = links[i].onClick; - links[i].onClick = (event) => { + links[i].onClick = (event: MouseEvent) => { // Allow opening in new tab if (!(event.ctrlKey || event.metaKey || event.shiftKey)) { event.preventDefault(); @@ -427,40 +271,48 @@ export const getCellLinks = (field: Field, rowIdx: number) => { }; /* ----------------------------- Data grid sorting ---------------------------- */ -export const handleSort = ( - columnKey: string, - direction: SortDirection, - isMultiSort: boolean, - setSortColumns: React.Dispatch>, - sortColumnsRef: React.MutableRefObject -) => { - let currentSortColumn: SortColumn | undefined; - - const updatedSortColumns = sortColumnsRef.current.filter((column) => { - const isCurrentColumn = column.columnKey === columnKey; - if (isCurrentColumn) { - currentSortColumn = column; - } - return !isCurrentColumn; - }); - - // sorted column exists and is descending -> remove it to reset sorting - if (currentSortColumn && currentSortColumn.direction === 'DESC') { - setSortColumns(updatedSortColumns); - sortColumnsRef.current = updatedSortColumns; - } else { - // new sort column or changed direction - if (isMultiSort) { - setSortColumns([...updatedSortColumns, { columnKey, direction }]); - sortColumnsRef.current = [...updatedSortColumns, { columnKey, direction }]; - } else { - setSortColumns([{ columnKey, direction }]); - sortColumnsRef.current = [{ columnKey, direction }]; - } +/** + * @internal + */ +export function applySort( + rows: TableRow[], + fields: Field[], + sortColumns: SortColumn[], + columnTypes: ColumnTypes = getColumnTypes(fields), + hasNestedFrames: boolean = getIsNestedTable(fields) +): TableRow[] { + if (sortColumns.length === 0) { + return rows; } -}; + + const compareRows = (a: TableRow, b: TableRow): number => { + let result = 0; + for (let i = 0; i < sortColumns.length; i++) { + const { columnKey, direction } = sortColumns[i]; + const compare = getComparator(columnTypes[columnKey]); + const sortDir = direction === 'ASC' ? 1 : -1; + + result = sortDir * compare(a[columnKey], b[columnKey]); + if (result !== 0) { + break; + } + } + return result; + }; + + // Handle nested tables + if (hasNestedFrames) { + return processNestedTableRows(rows, (parents) => [...parents].sort(compareRows)); + } + + // Regular sort for tables without nesting + return [...rows].sort(compareRows); +} /* ----------------------------- Data grid mapping ---------------------------- */ +/** + * @internal + */ export const frameToRecords = (frame: DataFrame): TableRow[] => { const fnBody = ` const rows = Array(frame.length); @@ -473,8 +325,8 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => { ${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')} }; rowCount += 1; - if (rows[rowCount-1]['Nested frames']){ - const childFrame = rows[rowCount-1]['Nested frames']; + if (rows[rowCount-1]['__nestedFrames']){ + const childFrame = rows[rowCount-1]['__nestedFrames']; rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]} rowCount += 1; } @@ -488,65 +340,68 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => { return convert(frame); }; -export interface MapFrameToGridOptions extends TableNGProps { - columnTypes: ColumnTypes; - columnWidth: number | string; - crossFilterOrder: React.MutableRefObject; - crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>; - defaultLineHeight: number; - defaultRowHeight: number; - expandedRows: number[]; - filter: FilterType; - headerCellRefs: React.MutableRefObject>; - isCountRowsSet: boolean; - ctx: CanvasRenderingContext2D; - onSortByChange?: (sortBy: TableSortByFieldState[]) => void; - rows: TableRow[]; - setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void; - setFilter: React.Dispatch>; - setIsInspecting: (isInspecting: boolean) => void; - setSortColumns: React.Dispatch>; - sortColumnsRef: React.MutableRefObject; - styles: { cell: string; cellWrapped: string; dataGrid: string }; - textWraps: Record; - theme: GrafanaTheme2; - showTypeIcons?: boolean; -} - /* ----------------------------- Data grid comparator ---------------------------- */ // The numeric: true option is used to sort numbers as strings correctly. It recognizes numeric sequences // within strings and sorts numerically instead of lexicographically. const compare = new Intl.Collator('en', { sensitivity: 'base', numeric: true }).compare; +const strCompare: Comparator = (a, b) => compare(String(a ?? ''), String(b ?? '')); +const numCompare: Comparator = (a, b) => { + if (a === b) { + return 0; + } + if (a == null) { + return -1; + } + if (b == null) { + return 1; + } + return Number(a) - Number(b); +}; +const frameCompare: Comparator = (a, b) => { + // @ts-ignore The compared vals are DataFrameWithValue. the value is the rendered stat (first, last, etc.) + return (a?.value ?? 0) - (b?.value ?? 0); +}; + +/** + * @internal + */ export function getComparator(sortColumnType: FieldType): Comparator { switch (sortColumnType) { // Handle sorting for frame type fields (sparklines) case FieldType.frame: - return (a, b) => { - // @ts-ignore The values are DataFrameWithValue - return (a?.value ?? 0) - (b?.value ?? 0); - }; + return frameCompare; case FieldType.time: case FieldType.number: case FieldType.boolean: - return (a, b) => { - if (a === b) { - return 0; - } - if (a == null) { - return -1; - } - if (b == null) { - return 1; - } - return Number(a) - Number(b); - }; + return numCompare; case FieldType.string: case FieldType.enum: default: - return (a, b) => compare(String(a ?? ''), String(b ?? '')); + return strCompare; } } +type TableCellGaugeDisplayModes = + | TableCellDisplayMode.BasicGauge + | TableCellDisplayMode.GradientGauge + | TableCellDisplayMode.LcdGauge; +const TABLE_CELL_GAUGE_DISPLAY_MODES_TO_DISPLAY_MODES: Record = { + [TableCellDisplayMode.BasicGauge]: BarGaugeDisplayMode.Basic, + [TableCellDisplayMode.GradientGauge]: BarGaugeDisplayMode.Gradient, + [TableCellDisplayMode.LcdGauge]: BarGaugeDisplayMode.Lcd, +}; + +type TableCellColorBackgroundDisplayModes = + | TableCellDisplayMode.ColorBackground + | TableCellDisplayMode.ColorBackgroundSolid; +const TABLE_CELL_COLOR_BACKGROUND_DISPLAY_MODES_TO_DISPLAY_MODES: Record< + TableCellColorBackgroundDisplayModes, + TableCellBackgroundDisplayMode +> = { + [TableCellDisplayMode.ColorBackground]: TableCellBackgroundDisplayMode.Gradient, + [TableCellDisplayMode.ColorBackgroundSolid]: TableCellBackgroundDisplayMode.Basic, +}; + /* ---------------------------- Miscellaneous ---------------------------- */ /** * Migrates table cell display mode to new object format. @@ -558,49 +413,44 @@ export function getComparator(sortColumnType: FieldType): Comparator { export function migrateTableDisplayModeToCellOptions(displayMode: TableCellDisplayMode): TableCellOptions { switch (displayMode) { // In the case of the gauge we move to a different option - case 'basic': - case 'gradient-gauge': - case 'lcd-gauge': - let gaugeMode = BarGaugeDisplayMode.Basic; - - if (displayMode === 'gradient-gauge') { - gaugeMode = BarGaugeDisplayMode.Gradient; - } else if (displayMode === 'lcd-gauge') { - gaugeMode = BarGaugeDisplayMode.Lcd; - } - + case TableCellDisplayMode.BasicGauge: + case TableCellDisplayMode.GradientGauge: + case TableCellDisplayMode.LcdGauge: return { type: TableCellDisplayMode.Gauge, - mode: gaugeMode, + mode: TABLE_CELL_GAUGE_DISPLAY_MODES_TO_DISPLAY_MODES[displayMode], }; // Also true in the case of the color background - case 'color-background': - case 'color-background-solid': - let mode = TableCellBackgroundDisplayMode.Basic; - - // Set the new mode field, somewhat confusingly the - // color-background mode is for gradient display - if (displayMode === 'color-background') { - mode = TableCellBackgroundDisplayMode.Gradient; - } - + case TableCellDisplayMode.ColorBackground: + case TableCellDisplayMode.ColorBackgroundSolid: return { type: TableCellDisplayMode.ColorBackground, - mode: mode, + mode: TABLE_CELL_COLOR_BACKGROUND_DISPLAY_MODES_TO_DISPLAY_MODES[displayMode], + }; + // catching a nonsense case: `displayMode`: 'custom' should pre-date the CustomCell. + // if it doesn't, we need to just nope out and return an auto cell. + case TableCellDisplayMode.Custom: + return { + type: TableCellDisplayMode.Auto, }; default: return { - // @ts-ignore type: displayMode, }; } } -/** Returns true if the DataFrame contains nested frames */ -export const getIsNestedTable = (dataFrame: DataFrame): boolean => - dataFrame.fields.some(({ type }) => type === FieldType.nestedFrames); +/** + * @internal + * Returns true if the DataFrame contains nested frames + */ +export const getIsNestedTable = (fields: Field[]): boolean => + fields.some(({ type }) => type === FieldType.nestedFrames); -/** Processes nested table rows */ +/** + * @internal + * Processes nested table rows + */ export const processNestedTableRows = ( rows: TableRow[], processParents: (parents: TableRow[]) => TableRow[] @@ -611,13 +461,13 @@ export const processNestedTableRows = ( const parentRows: TableRow[] = []; const childRows: Map = new Map(); - rows.forEach((row) => { + for (const row of rows) { if (Number(row.__depth) === 0) { parentRows.push(row); } else { childRows.set(Number(row.__index), row); } - }); + } // Process parent rows (filter or sort) const processedParents = processParents(parentRows); @@ -635,6 +485,84 @@ export const processNestedTableRows = ( return result; }; +/** + * @internal + * returns the display name of a field + */ export const getDisplayName = (field: Field): string => { return field.state?.displayName ?? field.name; }; + +/** + * @internal + * returns only fields that are not nested tables and not explicitly hidden + */ +export function getVisibleFields(fields: Field[]): Field[] { + return fields.filter((field) => field.type !== FieldType.nestedFrames && field.config.custom?.hidden !== true); +} + +/** + * @internal + * returns a map of column types by display name + */ +export function getColumnTypes(fields: Field[]): ColumnTypes { + return fields.reduce((acc, field) => { + switch (field.type) { + case FieldType.nestedFrames: + return { ...acc, ...getColumnTypes(field.values[0]?.[0]?.fields ?? []) }; + default: + return { ...acc, [getDisplayName(field)]: field.type }; + } + }, {}); +} + +/** + * @internal + * calculates the width of each field, with the following logic: + * 1. manual sizing minWidth is hard-coded to 50px, we set this in RDG since it enforces the hard limit correctly + * 2. if minWidth is configured in fieldConfig (or defaults to 150), it serves as the bottom of the auto-size clamp + */ +export function computeColWidths(fields: Field[], availWidth: number) { + let autoCount = 0; + let definedWidth = 0; + + return ( + fields + // first pass to add up how many fields have pre-defined widths and what that width totals to. + .map((field) => { + const width: number = field.config.custom?.width ?? 0; + + if (width === 0) { + autoCount++; + } else { + definedWidth += width; + } + + return width; + }) + // second pass once `autoCount` and `definedWidth` are known. + .map( + (width, i) => + width || + Math.max(fields[i].config.custom?.minWidth ?? COLUMN.DEFAULT_WIDTH, (availWidth - definedWidth) / autoCount) + ) + ); +} + +/** + * @internal + * if applyToRow is true in any field, return a function that gets the row background color + */ +export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowIndex: number) => CellColors) | void { + for (const field of fields) { + const cellOptions = getCellOptions(field); + const fieldDisplay = field.display; + if ( + fieldDisplay !== undefined && + cellOptions.type === TableCellDisplayMode.ColorBackground && + cellOptions.applyToRow === true + ) { + return (rowIndex: number) => getCellColors(theme, cellOptions, fieldDisplay(field.values[rowIndex])); + } + } +} diff --git a/public/app/plugins/panel/table/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/TableCellOptionEditor.tsx index 39b2059d760..ad5d9e400be 100644 --- a/public/app/plugins/panel/table/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/TableCellOptionEditor.tsx @@ -41,7 +41,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When changing cell type see if there were previously stored // settings and merge those with the changed value if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) { - value = merge(value, settingCache[value.type]); + value = merge({}, value, settingCache[value.type]); } onChange(value); @@ -51,7 +51,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When options for a cell change we merge // any option changes with our options object const onCellOptionsChange = (options: TableCellOptions) => { - settingCache[value.type] = merge(value, options); + settingCache[value.type] = merge({}, value, options); setSettingCache(settingCache); onChange(settingCache[value.type]); }; diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 66250d8ffaf..4b55b60031b 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -51,7 +51,6 @@ export const plugin = new PanelPlugin(TablePanel) settings: { placeholder: t('table.placeholder-column-width', 'auto'), min: 20, - max: 300, }, shouldApply: () => true, defaultValue: defaultTableFieldOptions.width, diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx index 39b2059d760..ad5d9e400be 100644 --- a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx @@ -41,7 +41,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When changing cell type see if there were previously stored // settings and merge those with the changed value if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) { - value = merge(value, settingCache[value.type]); + value = merge({}, value, settingCache[value.type]); } onChange(value); @@ -51,7 +51,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When options for a cell change we merge // any option changes with our options object const onCellOptionsChange = (options: TableCellOptions) => { - settingCache[value.type] = merge(value, options); + settingCache[value.type] = merge({}, value, options); setSettingCache(settingCache); onChange(settingCache[value.type]); }; diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx index 711d2d50579..903a74f65c3 100644 --- a/public/app/plugins/panel/table/table-new/TablePanel.tsx +++ b/public/app/plugins/panel/table/table-new/TablePanel.tsx @@ -77,6 +77,7 @@ export function TablePanel(props: Props) { fieldConfig={fieldConfig} getActions={getCellActions} replaceVariables={replaceVariables} + structureRev={data.structureRev} /> ); diff --git a/public/app/plugins/panel/table/table-new/module.tsx b/public/app/plugins/panel/table/table-new/module.tsx index ecbe9cbf782..06d61bc11f9 100644 --- a/public/app/plugins/panel/table/table-new/module.tsx +++ b/public/app/plugins/panel/table/table-new/module.tsx @@ -51,7 +51,6 @@ export const plugin = new PanelPlugin(TablePanel) settings: { placeholder: t('table-new.placeholder-column-width', 'auto'), min: 20, - max: 300, }, shouldApply: () => true, defaultValue: defaultTableFieldOptions.width, diff --git a/yarn.lock b/yarn.lock index 2556424e144..977c1b5cb1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3775,7 +3775,7 @@ __metadata: react-calendar: "npm:^5.1.0" react-colorful: "npm:5.6.1" react-custom-scrollbars-2: "npm:4.5.0" - react-data-grid: "grafana/react-data-grid#3420f7f2a9e0d707d3313ec5b143a6be53f720b5" + react-data-grid: "grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6" react-dom: "npm:18.3.1" react-dropzone: "npm:14.3.8" react-highlight-words: "npm:0.21.0" @@ -26659,15 +26659,15 @@ __metadata: languageName: node linkType: hard -"react-data-grid@grafana/react-data-grid#3420f7f2a9e0d707d3313ec5b143a6be53f720b5": - version: 7.0.0-beta.55 - resolution: "react-data-grid@https://github.com/grafana/react-data-grid.git#commit=3420f7f2a9e0d707d3313ec5b143a6be53f720b5" +"react-data-grid@grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6": + version: 7.0.0-beta.56 + resolution: "react-data-grid@https://github.com/grafana/react-data-grid.git#commit=de920f0105cb2b7d774444e7443a675f3b568ad6" dependencies: clsx: "npm:^2.0.0" peerDependencies: react: ^18.0 || ^19.0 react-dom: ^18.0 || ^19.0 - checksum: 10/9fe309924a7b22d0a62f0df69bdc7b9e0df62c5624e96125f2d729500dfe103037cfbe654fa63294d89fc9b5fea618a540160e2e12e7de4c7052049827df3687 + checksum: 10/efc1dcb764fa5f3549d012737e79d423b34e6ad7b0a122849d757f59b45e648afe7c5cfeb1a61464245aa7b393f9251ff91fd49f8f857139d7df185c23d68b68 languageName: node linkType: hard