mirror of https://github.com/grafana/grafana
TableNG: Refactor to better take advantage of react-data-grid (#103755)
parent
7940da4803
commit
99782ae406
@ -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; |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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; |
||||
} |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue