TableNG: Refactor to better take advantage of react-data-grid (#103755)
This commit is contained in:
+3
-9
@@ -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');
|
||||
const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] > div:last-child');
|
||||
const handle = resizeHandles[0];
|
||||
|
||||
// TODO: This `if` doesn't even trigger - the test is evergreen.
|
||||
// We should work out a reliable way to actually find and trigger the resize methods
|
||||
// The querySelector doesn't return anything!
|
||||
if (resizeHandles.length > 0) {
|
||||
// Simulate resize by triggering mousedown, mousemove, mouseup
|
||||
/* eslint-disable testing-library/prefer-user-event */
|
||||
fireEvent.mouseDown(resizeHandles[0]);
|
||||
fireEvent.mouseMove(resizeHandles[0], { clientX: 250 });
|
||||
fireEvent.mouseUp(resizeHandles[0]);
|
||||
/* eslint-enable testing-library/prefer-user-event */
|
||||
|
||||
// Check that onColumnResize was called
|
||||
expect(onColumnResize).toHaveBeenCalled();
|
||||
if (!handle) {
|
||||
throw new Error('Resize handle not found');
|
||||
}
|
||||
|
||||
// simulate a click, then drag, then release.
|
||||
await userEvent.pointer({ keys: '[MouseLeft>]', coords: { x: 0, y: 0 }, target: handle });
|
||||
await userEvent.pointer({ coords: { x: 250, y: 0 }, target: handle });
|
||||
await userEvent.pointer({ keys: '[/MouseLeft]', coords: { x: 250, y: 0 }, target: handle });
|
||||
|
||||
await waitFor(() => expect(onColumnResize).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1201,7 +1291,7 @@ describe('TableNG', () => {
|
||||
|
||||
const cells = container.querySelectorAll('[role="gridcell"]');
|
||||
const cellStyles = window.getComputedStyle(cells[0]);
|
||||
expect(cellStyles.getPropertyValue('white-space')).toBe('nowrap');
|
||||
expect(cellStyles.getPropertyValue('white-space')).not.toBe('pre-line');
|
||||
});
|
||||
|
||||
it('applies text wrapping styles when wrapText is true', () => {
|
||||
@@ -1265,6 +1355,10 @@ describe('TableNG', () => {
|
||||
// Check for the Inspect value menu item
|
||||
const menuItem = await screen.findByText('Inspect value');
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
|
||||
// close the menu
|
||||
await userEvent.click(container);
|
||||
expect(menuItem).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1371,8 +1465,8 @@ describe('TableNG', () => {
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the first div inside the cell for style attributes
|
||||
const div = cells[0].querySelectorAll('div')[0];
|
||||
const styleAttr = window.getComputedStyle(div);
|
||||
const cell = cells[0];
|
||||
const styleAttr = window.getComputedStyle(cell);
|
||||
|
||||
// Expected color is red
|
||||
expect(styleAttr.background).toBe('rgb(255, 0, 0)');
|
||||
@@ -1408,11 +1502,50 @@ describe('TableNG', () => {
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the first div inside the cell for style attributes
|
||||
const div = cells[0].querySelectorAll('div')[0];
|
||||
const computedStyle = window.getComputedStyle(div);
|
||||
const cell = cells[0];
|
||||
const computedStyle = window.getComputedStyle(cell);
|
||||
|
||||
// Expected color is red
|
||||
expect(computedStyle.color).toBe('rgb(255, 0, 0)');
|
||||
|
||||
// doesn't accidentally applyToRow
|
||||
const otherCell = cells[1];
|
||||
expect(window.getComputedStyle(otherCell).color).not.toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
it("renders the background color correclty when using 'ColorBackground' display mode and applyToRow is true", () => {
|
||||
// Create a frame with color background cells and applyToRow set to true
|
||||
const frame = createBasicDataFrame();
|
||||
frame.fields[0].config.custom = {
|
||||
...frame.fields[0].config.custom,
|
||||
cellOptions: {
|
||||
type: TableCellDisplayMode.ColorBackground,
|
||||
applyToRow: true,
|
||||
mode: TableCellBackgroundDisplayMode.Basic,
|
||||
},
|
||||
};
|
||||
|
||||
// Add color to the display values
|
||||
const originalDisplay = frame.fields[0].display;
|
||||
const expectedColor = '#ff0000'; // Red color
|
||||
frame.fields[0].display = (value: unknown) => {
|
||||
const displayValue = originalDisplay ? originalDisplay(value) : { text: String(value), numeric: 0 };
|
||||
return {
|
||||
...displayValue,
|
||||
color: expectedColor,
|
||||
};
|
||||
};
|
||||
|
||||
const { container } = render(<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;
|
||||
|
||||
onRowHover(idx, mockPanelContext, frame, true);
|
||||
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
point: {
|
||||
time: new Date('2024-03-20T10:01:00Z').getTime(),
|
||||
},
|
||||
},
|
||||
type: 'data-hover',
|
||||
})
|
||||
it('should publish DataHoverEvent when hovering over a row with time field', async () => {
|
||||
const data = createTimeDataFrame();
|
||||
render(
|
||||
<PanelContextProvider value={mockPanelContext}>
|
||||
<TableNG enableVirtualization={false} data={data} width={800} height={600} enableSharedCrosshair />
|
||||
</PanelContextProvider>
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getAllByRole('row')[1]);
|
||||
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
point: {
|
||||
time: data.fields[0].values[0],
|
||||
},
|
||||
},
|
||||
type: 'data-hover',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not publish DataHoverEvent when enableSharedCrosshair is false', () => {
|
||||
const frame = createTimeDataFrame();
|
||||
const idx = 1;
|
||||
it('should not publish DataHoverEvent when enableSharedCrosshair is false', async () => {
|
||||
render(
|
||||
<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);
|
||||
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>
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getAllByRole('row')[1]);
|
||||
await userEvent.unhover(screen.getAllByRole('row')[1]);
|
||||
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('scroll position persistence', () => {
|
||||
it('should persist scroll position after revId change', () => {
|
||||
const data = createBasicDataFrame();
|
||||
const { rerender } = render(<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: [],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify scroll position was restored
|
||||
expect(dataGrid.scrollLeft).toBe(100);
|
||||
expect(dataGrid.scrollTop).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
frame: DataFrame;
|
||||
getActions?: GetActionsFunction;
|
||||
height: number;
|
||||
justifyContent: Property.JustifyContent;
|
||||
export interface TableCellRendererProps {
|
||||
actions?: ActionModel[];
|
||||
rowIdx: number;
|
||||
setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void;
|
||||
setIsInspecting: (isInspecting: boolean) => void;
|
||||
shouldTextOverflow: () => boolean;
|
||||
theme: GrafanaTheme2;
|
||||
timeRange: TimeRange;
|
||||
frame: DataFrame;
|
||||
timeRange?: TimeRange;
|
||||
value: TableCellValue;
|
||||
rowBg: Function | undefined;
|
||||
height: number;
|
||||
// flags that are static per column
|
||||
field: Field;
|
||||
cellOptions: TableCellOptions;
|
||||
width: number;
|
||||
theme: GrafanaTheme2;
|
||||
cellInspect: boolean;
|
||||
showFilters: boolean;
|
||||
justifyContent: Property.JustifyContent;
|
||||
}
|
||||
|
||||
export type ContextMenuProps = {
|
||||
rowIdx?: number;
|
||||
value: string;
|
||||
mode?: TableCellInspectorMode.code | TableCellInspectorMode.text;
|
||||
top?: number;
|
||||
left?: number;
|
||||
};
|
||||
|
||||
export interface TableCellActionsProps {
|
||||
field: Field;
|
||||
value: TableCellValue;
|
||||
cellOptions: TableCellOptions;
|
||||
displayName: string;
|
||||
cellInspect: boolean;
|
||||
showFilters: boolean;
|
||||
setIsInspecting: React.Dispatch<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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
});
|
||||
|
||||
// sorted column exists and is descending -> remove it to reset sorting
|
||||
if (currentSortColumn && currentSortColumn.direction === 'DESC') {
|
||||
setSortColumns(updatedSortColumns);
|
||||
sortColumnsRef.current = updatedSortColumns;
|
||||
} else {
|
||||
// new sort column or changed direction
|
||||
if (isMultiSort) {
|
||||
setSortColumns([...updatedSortColumns, { columnKey, direction }]);
|
||||
sortColumnsRef.current = [...updatedSortColumns, { columnKey, direction }];
|
||||
} else {
|
||||
setSortColumns([{ columnKey, direction }]);
|
||||
sortColumnsRef.current = [{ columnKey, direction }];
|
||||
}
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function applySort(
|
||||
rows: TableRow[],
|
||||
fields: Field[],
|
||||
sortColumns: SortColumn[],
|
||||
columnTypes: ColumnTypes = getColumnTypes(fields),
|
||||
hasNestedFrames: boolean = getIsNestedTable(fields)
|
||||
): TableRow[] {
|
||||
if (sortColumns.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
};
|
||||
|
||||
const compareRows = (a: TableRow, b: TableRow): number => {
|
||||
let result = 0;
|
||||
for (let i = 0; i < sortColumns.length; i++) {
|
||||
const { columnKey, direction } = sortColumns[i];
|
||||
const compare = getComparator(columnTypes[columnKey]);
|
||||
const sortDir = direction === 'ASC' ? 1 : -1;
|
||||
|
||||
result = sortDir * compare(a[columnKey], b[columnKey]);
|
||||
if (result !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Handle nested tables
|
||||
if (hasNestedFrames) {
|
||||
return processNestedTableRows(rows, (parents) => [...parents].sort(compareRows));
|
||||
}
|
||||
|
||||
// Regular sort for tables without nesting
|
||||
return [...rows].sort(compareRows);
|
||||
}
|
||||
|
||||
/* ----------------------------- Data grid mapping ---------------------------- */
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const frameToRecords = (frame: DataFrame): TableRow[] => {
|
||||
const fnBody = `
|
||||
const rows = Array(frame.length);
|
||||
@@ -473,8 +325,8 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => {
|
||||
${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')}
|
||||
};
|
||||
rowCount += 1;
|
||||
if (rows[rowCount-1]['Nested frames']){
|
||||
const childFrame = rows[rowCount-1]['Nested frames'];
|
||||
if (rows[rowCount-1]['__nestedFrames']){
|
||||
const childFrame = rows[rowCount-1]['__nestedFrames'];
|
||||
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
|
||||
rowCount += 1;
|
||||
}
|
||||
@@ -488,65 +340,68 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => {
|
||||
return convert(frame);
|
||||
};
|
||||
|
||||
export interface MapFrameToGridOptions extends TableNGProps {
|
||||
columnTypes: ColumnTypes;
|
||||
columnWidth: number | string;
|
||||
crossFilterOrder: React.MutableRefObject<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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user