TableNG: Refactor to better take advantage of react-data-grid (#103755)

pull/106160/head
Leon Sorokin 3 weeks ago committed by GitHub
parent 7940da4803
commit 99782ae406
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      .betterer.results
  2. 4
      packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts
  3. 2
      packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts
  4. 2
      packages/grafana-ui/package.json
  5. 2
      packages/grafana-ui/src/components/BarGauge/BarGauge.tsx
  6. 3
      packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx
  7. 3
      packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx
  8. 3
      packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx
  9. 110
      packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx
  10. 2
      packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx
  11. 2
      packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx
  12. 2
      packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx
  13. 82
      packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx
  14. 284
      packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx
  15. 343
      packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx
  16. 135
      packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx
  17. 22
      packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx
  18. 3
      packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx
  19. 409
      packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx
  20. 1490
      packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
  21. 3
      packages/grafana-ui/src/components/Table/TableNG/constants.ts
  22. 335
      packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts
  23. 506
      packages/grafana-ui/src/components/Table/TableNG/hooks.ts
  24. 73
      packages/grafana-ui/src/components/Table/TableNG/types.ts
  25. 1192
      packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
  26. 526
      packages/grafana-ui/src/components/Table/TableNG/utils.ts
  27. 4
      public/app/plugins/panel/table/TableCellOptionEditor.tsx
  28. 1
      public/app/plugins/panel/table/module.tsx
  29. 4
      public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
  30. 1
      public/app/plugins/panel/table/table-new/TablePanel.tsx
  31. 1
      public/app/plugins/panel/table/table-new/module.tsx
  32. 10
      yarn.lock

@ -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"]

@ -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: [
[

@ -124,7 +124,7 @@ export const groupToNestedTable: DataTransformerInfo<GroupToNestedTableTransform
fields.push({
config: {},
name: 'Nested frames',
name: '__nestedFrames',
type: FieldType.nestedFrames,
values: subFrames,
});

@ -107,7 +107,7 @@
"react-calendar": "^5.1.0",
"react-colorful": "5.6.1",
"react-custom-scrollbars-2": "4.5.0",
"react-data-grid": "grafana/react-data-grid#3420f7f2a9e0d707d3313ec5b143a6be53f720b5",
"react-data-grid": "grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6",
"react-dropzone": "14.3.8",
"react-highlight-words": "0.21.0",
"react-hook-form": "^7.49.2",

@ -688,7 +688,7 @@ function getValueStyles(
const styles: CSSProperties = {
color,
height: `${height}px`,
width: `${width}px`,
maxWidth: `${width}px`,
display: 'flex',
alignItems: 'center',
textWrap: 'nowrap',

@ -3,7 +3,8 @@ import userEvent from '@testing-library/user-event';
import { Field, FieldType, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TableCellDisplayMode } from '@grafana/schema';
import { TableCellDisplayMode } from '../../types';
import AutoCell from './AutoCell';

@ -4,10 +4,10 @@ import { useState } from 'react';
import { GrafanaTheme2, formattedValueToString } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TableCellDisplayMode, TableCellOptions } from '@grafana/schema';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { TableCellOptions, TableCellDisplayMode } from '../../types';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { AutoCellProps } from '../types';
import { getCellLinks } from '../utils';
@ -60,7 +60,6 @@ const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent
cell: css({
display: 'flex',
justifyContent: justifyContent,
a: {
color: 'inherit',
},

@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { Field, FieldType, LinkModel } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/schema';
import { TableCellDisplayMode } from '../../types';
import { DataLinksCell } from './DataLinksCell';

@ -1,6 +1,5 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import React, { useLayoutEffect, useRef, useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import { Column, SortDirection } from 'react-data-grid';
import { Field, GrafanaTheme2 } from '@grafana/data';
@ -9,22 +8,18 @@ import { useStyles2 } from '../../../../themes/ThemeContext';
import { getFieldTypeIcon } from '../../../../types/icon';
import { Icon } from '../../../Icon/Icon';
import { Filter } from '../Filter/Filter';
import { TableColumnResizeActionCallback, FilterType, TableRow, TableSummaryRow } from '../types';
import { FilterType, TableRow, TableSummaryRow } from '../types';
import { getDisplayName } from '../utils';
interface HeaderCellProps {
column: Column<TableRow, TableSummaryRow>;
rows: TableRow[];
field: Field;
onSort: (columnKey: string, direction: SortDirection, isMultiSort: boolean) => void;
direction?: SortDirection;
justifyContent: Property.JustifyContent;
filter: FilterType;
setFilter: React.Dispatch<React.SetStateAction<FilterType>>;
onColumnResize?: TableColumnResizeActionCallback;
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
crossFilterOrder: React.MutableRefObject<string[]>;
crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>;
crossFilterOrder: string[];
crossFilterRows: { [key: string]: TableRow[] };
showTypeIcons?: boolean;
}
@ -32,113 +27,54 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
column,
rows,
field,
onSort,
direction,
justifyContent,
filter,
setFilter,
onColumnResize,
headerCellRefs,
crossFilterOrder,
crossFilterRows,
showTypeIcons,
}) => {
const styles = useStyles2(getStyles, justifyContent);
const headerRef = useRef<HTMLDivElement>(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<HTMLButtonElement>) => {
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
<div
ref={headerRef}
className={styles.headerCell}
// TODO find a better solution to this issue, see: https://github.com/adazzle/react-data-grid/issues/3535
// Unblock spacebar event
onKeyDown={(event) => {
if (event.key === ' ') {
event.stopPropagation();
}
}}
>
<button className={styles.headerCellLabel} onClick={handleSort}>
<>
<span className={styles.headerCellLabel}>
{showTypeIcons && <Icon name={getFieldTypeIcon(field)} title={field?.type} size="sm" />}
{/* Used cached displayName if available, otherwise use the column name (nested tables) */}
<div>{field.state?.displayName ?? column.name}</div>
<div>{getDisplayName(field)}</div>
{direction && (direction === 'ASC' ? <Icon name="arrow-up" size="lg" /> : <Icon name="arrow-down" size="lg" />)}
</button>
</span>
{isColumnFilterable && (
{filterable && (
<Filter
name={column.key}
rows={rows}
filter={filter}
setFilter={setFilter}
field={field}
crossFilterOrder={crossFilterOrder.current}
crossFilterRows={crossFilterRows.current}
crossFilterOrder={crossFilterOrder}
crossFilterRows={crossFilterRows}
/>
)}
</div>
</>
);
};
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,

@ -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';

@ -12,7 +12,7 @@ export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGP
function handleKeyDown(e: React.KeyboardEvent<HTMLSpanElement>) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onCellExpand();
onCellExpand(e);
}
}
return (

@ -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) {

@ -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 (
<div className={className}>
{cellInspect && (
<IconButton
name="eye"
aria-label={t('grafana-ui.table.cell-inspect-tooltip', 'Inspect value')}
onClick={() => {
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 && (
<>
<IconButton
name={'search-plus'}
aria-label={t('grafana-ui.table.cell-filter-on', 'Filter for value')}
onClick={() => {
onCellFilterAdded?.({
key: displayName,
operator: FILTER_FOR_OPERATOR,
value: String(value ?? ''),
});
}}
/>
<IconButton
name={'search-minus'}
aria-label={t('grafana-ui.table.cell-filter-out', 'Filter out value')}
onClick={() => {
onCellFilterAdded?.({
key: displayName,
operator: FILTER_OUT_OPERATOR,
value: String(value ?? ''),
});
}}
/>
</>
)}
</div>
);
}

@ -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<HTMLDivElement>(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 = <SparklineCell {...commonProps} theme={theme} timeRange={timeRange} width={divWidth} />;
break;
case TableCellDisplayMode.Gauge:
case TableCellDisplayMode.BasicGauge:
case TableCellDisplayMode.GradientGauge:
case TableCellDisplayMode.LcdGauge:
cell = (
<BarGaugeCell
{...commonProps}
theme={theme}
timeRange={timeRange}
height={height}
width={divWidth}
actions={actions}
/>
);
break;
case TableCellDisplayMode.Image:
cell = <ImageCell {...commonProps} cellOptions={cellOptions} height={height} actions={actions} />;
break;
case TableCellDisplayMode.JSONView:
cell = <JSONCell {...commonProps} actions={actions} />;
break;
case TableCellDisplayMode.DataLinks:
cell = <DataLinksCell field={field} rowIdx={rowIdx} />;
break;
case TableCellDisplayMode.Actions:
cell = <ActionsCell actions={actions} />;
break;
case TableCellDisplayMode.Custom:
const CustomCellComponent: React.ComponentType<CustomCellRendererProps> = cellOptions.cellComponent;
cell = <CustomCellComponent field={field} value={value} rowIndex={rowIdx} frame={frame} />;
break;
case TableCellDisplayMode.Auto:
default:
// Handle auto cell type detection
if (field.type === FieldType.geo) {
cell = <GeoCell {...commonProps} height={height} />;
} else if (field.type === FieldType.frame) {
const firstValue = field.values[0];
if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) {
cell = <SparklineCell {...commonProps} theme={theme} timeRange={timeRange} width={divWidth} />;
} else {
cell = <JSONCell {...commonProps} actions={actions} />;
}
} else if (field.type === FieldType.other) {
cell = <JSONCell {...commonProps} actions={actions} />;
} else {
cell = <AutoCell {...commonProps} cellOptions={cellOptions} actions={actions} />;
}
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 (
<div ref={divWidthRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className={styles.cell}>
{renderedCell}
{isHovered && (cellInspect || showFilters) && (
<div className={styles.cellActions}>
{cellInspect && (
<IconButton
name="eye"
tooltip={t('grafana-ui.table.cell-inspect-tooltip', 'Inspect value')}
onClick={() => {
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 && (
<>
<IconButton
name={'search-plus'}
onClick={onFilterFor}
tooltip={t('grafana-ui.table.cell-filter-on', 'Filter for value')}
/>
<IconButton
name={'search-minus'}
onClick={onFilterOut}
tooltip={t('grafana-ui.table.cell-filter-out', 'Filter out value')}
/>
</>
)}
</div>
)}
</div>
);
}
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',
}),
});

@ -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<V>(type: FieldType, values: V[] = []): Field<V> {
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 = () => <div data-testid="custom-cell">CustomCell</div>;
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 = () => <div data-testid="custom-cell">CustomCell</div>;
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);
});
});
});
});
});

@ -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) => (
<BarGaugeCell
field={props.field}
value={props.value}
theme={props.theme}
height={props.height}
width={props.width}
rowIdx={props.rowIdx}
actions={props.actions}
/>
);
const AUTO_RENDERER: TableCellRenderer = (props) => (
<AutoCell
value={props.value}
field={props.field}
justifyContent={props.justifyContent}
rowIdx={props.rowIdx}
cellOptions={props.cellOptions}
actions={props.actions}
/>
);
const SPARKLINE_RENDERER: TableCellRenderer = (props) => (
<SparklineCell
value={props.value}
field={props.field}
justifyContent={props.justifyContent}
timeRange={props.timeRange}
rowIdx={props.rowIdx}
theme={props.theme}
width={props.width}
/>
);
const JSON_RENDERER: TableCellRenderer = (props) => (
<JSONCell
justifyContent={props.justifyContent}
value={props.value}
field={props.field}
rowIdx={props.rowIdx}
actions={props.actions}
/>
);
const GEO_RENDERER: TableCellRenderer = (props) => (
<GeoCell value={props.value} justifyContent={props.justifyContent} height={props.height} />
);
const IMAGE_RENDERER: TableCellRenderer = (props) => (
<ImageCell
cellOptions={props.cellOptions}
field={props.field}
height={props.height}
justifyContent={props.justifyContent}
value={props.value}
rowIdx={props.rowIdx}
actions={props.actions}
/>
);
const DATA_LINKS_RENDERER: TableCellRenderer = (props) => <DataLinksCell field={props.field} rowIdx={props.rowIdx} />;
const ACTIONS_RENDERER: TableCellRenderer = (props) => <ActionsCell actions={props.actions} />;
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 <CustomCellComponent field={props.field} rowIndex={props.rowIdx} frame={props.frame} value={props.value} />;
};
const CELL_RENDERERS: Record<TableCellOptions['type'], TableCellRenderer> = {
[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;
}

@ -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<boolean>(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<SelectableValue<string>>(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();
}}
>
<Icon name="filter" />
{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

@ -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 = ({
<div className={styles.filterContainer} onClick={stopPropagation}>
<Stack direction="column">
<Stack alignItems="center">
{field && <Label className={styles.label}>{field.config.displayName || field.name}</Label>}
{field && <Label className={styles.label}>{getDisplayName(field)}</Label>}
<ButtonSelect
variant="canvas"
options={OPERATORS}

@ -1,12 +1,13 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { applyFieldOverrides, createTheme, DataFrame, FieldType, toDataFrame, EventBus } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/schema';
import { applyFieldOverrides, createTheme, DataFrame, EventBus, FieldType, toDataFrame } from '@grafana/data';
import { TableCellBackgroundDisplayMode } from '@grafana/schema';
import { PanelContext } from '../../PanelChrome';
import { PanelContext, PanelContextProvider } from '../../../components/PanelChrome';
import { TableCellDisplayMode } from '../types';
import { TableNG, onRowHover, onRowLeave } from './TableNG';
import { TableNG } from './TableNG';
// Create a basic data frame for testing
const createBasicDataFrame = (): DataFrame => {
@ -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(
<TableNG enableVirtualization={false} data={createBasicDataFrame()} width={800} height={600} />
);
@ -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(
<TableNG enableVirtualization={false} data={createBasicDataFrame()} width={800} height={600} />
);
@ -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(
<TableNG enableVirtualization={false} data={createBasicDataFrame()} width={800} height={600} />
);
@ -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(
<TableNG enableVirtualization={false} data={createSortingTestDataFrame()} width={800} height={600} />
);
@ -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(
<TableNG
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
onSortByChange={onSortByChange}
/>
);
// 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');
// 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();
const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] > div:last-child');
const handle = resizeHandles[0];
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(<TableNG enableVirtualization={false} data={frame} width={800} height={600} />);
// 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;
it('should publish DataHoverEvent when hovering over a row with time field', async () => {
const data = createTimeDataFrame();
render(
<PanelContextProvider value={mockPanelContext}>
<TableNG enableVirtualization={false} data={data} width={800} height={600} enableSharedCrosshair />
</PanelContextProvider>
);
onRowHover(idx, mockPanelContext, frame, true);
await userEvent.hover(screen.getAllByRole('row')[1]);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
point: {
time: new Date('2024-03-20T10:01:00Z').getTime(),
},
expect(mockEventBus.publish).toHaveBeenCalledWith({
payload: {
point: {
time: data.fields[0].values[0],
},
type: 'data-hover',
})
);
},
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(
<PanelContextProvider value={mockPanelContext}>
<TableNG
enableVirtualization={false}
data={createTimeDataFrame()}
width={800}
height={600}
enableSharedCrosshair={false}
/>
</PanelContextProvider>
);
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(
<PanelContextProvider value={mockPanelContext}>
<TableNG
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
enableSharedCrosshair
/>
</PanelContextProvider>
);
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(
<PanelContextProvider value={mockPanelContext}>
<TableNG
enableVirtualization={false}
data={createTimeDataFrame()}
width={800}
height={600}
enableSharedCrosshair
/>
</PanelContextProvider>
);
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);
expect(mockEventBus.publish).not.toHaveBeenCalled();
});
});
describe('scroll position persistence', () => {
it('should persist scroll position after revId change', () => {
const data = createBasicDataFrame();
const { rerender } = render(<TableNG data={data} width={300} height={200} enableVirtualization={false} />);
// 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(
<TableNG
data={data}
width={300}
height={200}
enableVirtualization={false}
fieldConfig={{
defaults: {
custom: {
width: 200, // Different width to trigger revId change
},
},
overrides: [],
}}
/>
it('should not publish DataHoverClearEvent when enableSharedCrosshair is false', async () => {
render(
<PanelContextProvider value={mockPanelContext}>
<TableNG
enableVirtualization={false}
data={createTimeDataFrame()}
width={800}
height={600}
enableSharedCrosshair={false}
/>
</PanelContextProvider>
);
// Verify scroll position was restored
expect(dataGrid.scrollLeft).toBe(100);
expect(dataGrid.scrollTop).toBe(50);
await userEvent.hover(screen.getAllByRole('row')[1]);
await userEvent.unhover(screen.getAllByRole('row')[1]);
expect(mockEventBus.publish).not.toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load Diff

@ -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,

@ -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']);
});
});
});

@ -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<React.SetStateAction<FilterType>>;
crossFilterOrder: string[];
crossFilterRows: Record<string, TableRow[]>;
}
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<FilterType>({});
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<React.SetStateAction<SortColumn[]>>;
}
export function useSortedRows(
rows: TableRow[],
fields: Field[],
{ initialSortBy, columnTypes, hasNestedFrames }: SortedRowsOptions
): SortedRowsResult {
const initialSortColumns = useMemo<SortColumn[]>(
() =>
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<SortColumn[]>(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<React.SetStateAction<number>>;
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<string, boolean> {
return useMemo(
() =>
fields.reduce<{ [key: string]: boolean }>((acc, field) => {
const cellOptions = getCellOptions(field);
const displayName = getDisplayName(field);
const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText;
return { ...acc, [displayName]: !!wrapText };
}, {}),
[fields]
);
}
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<string, boolean>
): 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<TableRow, TableSummaryRow>['onColumnResize'] {
// these must be refs. if we used setState, we would run into race conditions with these event listeners
const colResizeState = useRef<UseColumnResizeState>({ ...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<TableRow, TableSummaryRow>, 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;
}

@ -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<TableFieldOptions, 'cellOptions'> & {
headerComponent?: React.ComponentType<CustomHeaderRendererProps>;
};
export type FilterType = {
[key: string]: {
export type FilterType = Record<
string,
{
filteredSet: Set<string>;
};
};
filtered?: Array<SelectableValue<unknown>>;
searchFilter?: string;
operator?: SelectableValue<string>;
}
>;
/* ----------------------------- Table specific types ----------------------------- */
export interface TableSummaryRow {
@ -51,12 +58,9 @@ export interface TableSummaryRow {
}
export interface TableColumn extends Column<TableRow, TableSummaryRow> {
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;
export interface TableCellRendererProps {
actions?: ActionModel[];
rowIdx: number;
frame: DataFrame;
getActions?: GetActionsFunction;
timeRange?: TimeRange;
value: TableCellValue;
height: number;
justifyContent: Property.JustifyContent;
rowIdx: number;
setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void;
setIsInspecting: (isInspecting: boolean) => void;
shouldTextOverflow: () => boolean;
// flags that are static per column
field: Field;
cellOptions: TableCellOptions;
width: number;
theme: GrafanaTheme2;
timeRange: TimeRange;
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;
rowBg: Function | undefined;
cellOptions: TableCellOptions;
displayName: string;
cellInspect: boolean;
showFilters: boolean;
setIsInspecting: React.Dispatch<React.SetStateAction<boolean>>;
setContextMenuProps: React.Dispatch<React.SetStateAction<ContextMenuProps | null>>;
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 {

@ -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<string, number>;
fieldDisplayType: Record<string, TableCellDisplayMode>;
}
): 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<string, string>): boolean {
return columnTypes[key] === FieldType.string;
}
export function shouldTextOverflow(
key: string,
row: TableRow,
columnTypes: ColumnTypes,
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>,
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<React.SetStateAction<readonly SortColumn[]>>,
sortColumnsRef: React.MutableRefObject<readonly SortColumn[]>
) => {
let currentSortColumn: SortColumn | undefined;
const updatedSortColumns = sortColumnsRef.current.filter((column) => {
const isCurrentColumn = column.columnKey === columnKey;
if (isCurrentColumn) {
currentSortColumn = column;
}
return !isCurrentColumn;
});
/**
* @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;
}
// 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 }];
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<string[]>;
crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>;
defaultLineHeight: number;
defaultRowHeight: number;
expandedRows: number[];
filter: FilterType;
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
isCountRowsSet: boolean;
ctx: CanvasRenderingContext2D;
onSortByChange?: (sortBy: TableSortByFieldState[]) => void;
rows: TableRow[];
setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void;
setFilter: React.Dispatch<React.SetStateAction<FilterType>>;
setIsInspecting: (isInspecting: boolean) => void;
setSortColumns: React.Dispatch<React.SetStateAction<readonly SortColumn[]>>;
sortColumnsRef: React.MutableRefObject<readonly SortColumn[]>;
styles: { cell: string; cellWrapped: string; dataGrid: string };
textWraps: Record<string, boolean>;
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<TableCellGaugeDisplayModes, BarGaugeDisplayMode> = {
[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<number, TableRow> = 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<ColumnTypes>((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]));
}
}
}

@ -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]);
};

@ -51,7 +51,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
settings: {
placeholder: t('table.placeholder-column-width', 'auto'),
min: 20,
max: 300,
},
shouldApply: () => true,
defaultValue: defaultTableFieldOptions.width,

@ -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]);
};

@ -77,6 +77,7 @@ export function TablePanel(props: Props) {
fieldConfig={fieldConfig}
getActions={getCellActions}
replaceVariables={replaceVariables}
structureRev={data.structureRev}
/>
);

@ -51,7 +51,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
settings: {
placeholder: t('table-new.placeholder-column-width', 'auto'),
min: 20,
max: 300,
},
shouldApply: () => true,
defaultValue: defaultTableFieldOptions.width,

@ -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

Loading…
Cancel
Save