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