mirror of https://github.com/grafana/grafana
Table: Highlight row on shared crosshair (#78392)
* bidirectional shared crosshair table WIP * add shared crosshair to table panel * lower around point threshold * add feature toggle * add index based verification * add adaptive threshold * switch to debounceTime * lower debounce to 100 * raise debounce back to 200 * revert azure dashboard * re-render only rows list on data hover event * further break down table component * refactor * raise debounce time * fix buildpull/79441/head
parent
7a006c32bb
commit
5aff3389f4
@ -0,0 +1,301 @@ |
|||||||
|
import { css, cx } from '@emotion/css'; |
||||||
|
import React, { CSSProperties, UIEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; |
||||||
|
import { Cell, Row, TableState } from 'react-table'; |
||||||
|
import { VariableSizeList } from 'react-window'; |
||||||
|
import { Subscription, debounceTime } from 'rxjs'; |
||||||
|
|
||||||
|
import { |
||||||
|
DataFrame, |
||||||
|
DataHoverClearEvent, |
||||||
|
DataHoverEvent, |
||||||
|
Field, |
||||||
|
FieldType, |
||||||
|
TimeRange, |
||||||
|
hasTimeField, |
||||||
|
} from '@grafana/data'; |
||||||
|
import { TableCellHeight } from '@grafana/schema'; |
||||||
|
|
||||||
|
import { useTheme2 } from '../../themes'; |
||||||
|
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; |
||||||
|
import { usePanelContext } from '../PanelChrome'; |
||||||
|
|
||||||
|
import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow'; |
||||||
|
import { TableCell } from './TableCell'; |
||||||
|
import { TableStyles } from './styles'; |
||||||
|
import { TableFilterActionCallback } from './types'; |
||||||
|
import { calculateAroundPointThreshold, isPointTimeValAroundTableTimeVal } from './utils'; |
||||||
|
|
||||||
|
interface RowsListProps { |
||||||
|
data: DataFrame; |
||||||
|
rows: Row[]; |
||||||
|
enableSharedCrosshair: boolean; |
||||||
|
headerHeight: number; |
||||||
|
rowHeight: number; |
||||||
|
itemCount: number; |
||||||
|
pageIndex: number; |
||||||
|
listHeight: number; |
||||||
|
width: number; |
||||||
|
cellHeight?: TableCellHeight; |
||||||
|
listRef: React.RefObject<VariableSizeList>; |
||||||
|
tableState: TableState; |
||||||
|
tableStyles: TableStyles; |
||||||
|
nestedDataField?: Field; |
||||||
|
prepareRow: (row: Row) => void; |
||||||
|
onCellFilterAdded?: TableFilterActionCallback; |
||||||
|
timeRange?: TimeRange; |
||||||
|
footerPaginationEnabled: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export const RowsList = (props: RowsListProps) => { |
||||||
|
const { |
||||||
|
data, |
||||||
|
rows, |
||||||
|
headerHeight, |
||||||
|
footerPaginationEnabled, |
||||||
|
rowHeight, |
||||||
|
itemCount, |
||||||
|
pageIndex, |
||||||
|
tableState, |
||||||
|
prepareRow, |
||||||
|
onCellFilterAdded, |
||||||
|
width, |
||||||
|
cellHeight = TableCellHeight.Sm, |
||||||
|
timeRange, |
||||||
|
tableStyles, |
||||||
|
nestedDataField, |
||||||
|
listHeight, |
||||||
|
listRef, |
||||||
|
enableSharedCrosshair = false, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(undefined); |
||||||
|
|
||||||
|
const theme = useTheme2(); |
||||||
|
const panelContext = usePanelContext(); |
||||||
|
|
||||||
|
const threshold = useMemo(() => { |
||||||
|
const timeField = data.fields.find((f) => f.type === FieldType.time); |
||||||
|
|
||||||
|
if (!timeField) { |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
return calculateAroundPointThreshold(timeField); |
||||||
|
}, [data]); |
||||||
|
|
||||||
|
const onRowHover = useCallback( |
||||||
|
(idx: number, frame: DataFrame) => { |
||||||
|
if (!panelContext || !enableSharedCrosshair || !hasTimeField(frame)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!; |
||||||
|
|
||||||
|
panelContext.eventBus.publish( |
||||||
|
new DataHoverEvent({ |
||||||
|
point: { |
||||||
|
time: timeField.values[idx], |
||||||
|
}, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, |
||||||
|
[enableSharedCrosshair, panelContext] |
||||||
|
); |
||||||
|
|
||||||
|
const onRowLeave = useCallback(() => { |
||||||
|
if (!panelContext || !enableSharedCrosshair) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
panelContext.eventBus.publish(new DataHoverClearEvent()); |
||||||
|
}, [enableSharedCrosshair, panelContext]); |
||||||
|
|
||||||
|
const onDataHoverEvent = useCallback( |
||||||
|
(evt: DataHoverEvent) => { |
||||||
|
if (evt.payload.point?.time && evt.payload.rowIndex !== undefined) { |
||||||
|
const timeField = data.fields.find((f) => f.type === FieldType.time); |
||||||
|
const time = timeField!.values[evt.payload.rowIndex]; |
||||||
|
const pointTime = evt.payload.point.time; |
||||||
|
|
||||||
|
// If the time value of the hovered point is around the time value of the
|
||||||
|
// row with same index, highlight the row
|
||||||
|
if (isPointTimeValAroundTableTimeVal(pointTime, time, threshold)) { |
||||||
|
setRowHighlightIndex(evt.payload.rowIndex); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If the time value of the hovered point is not around the time value of the
|
||||||
|
// row with same index, try to find a row with same time value
|
||||||
|
const matchedRowIndex = timeField!.values.findIndex((t) => |
||||||
|
isPointTimeValAroundTableTimeVal(pointTime, t, threshold) |
||||||
|
); |
||||||
|
|
||||||
|
if (matchedRowIndex !== -1) { |
||||||
|
setRowHighlightIndex(matchedRowIndex); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setRowHighlightIndex(undefined); |
||||||
|
} |
||||||
|
}, |
||||||
|
[data.fields, threshold] |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!panelContext || !enableSharedCrosshair || !hasTimeField(data) || footerPaginationEnabled) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const subs = new Subscription(); |
||||||
|
|
||||||
|
subs.add( |
||||||
|
panelContext.eventBus |
||||||
|
.getStream(DataHoverEvent) |
||||||
|
.pipe(debounceTime(250)) |
||||||
|
.subscribe({ |
||||||
|
next: (evt) => { |
||||||
|
if (panelContext.eventBus === evt.origin) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
onDataHoverEvent(evt); |
||||||
|
}, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
subs.add( |
||||||
|
panelContext.eventBus |
||||||
|
.getStream(DataHoverClearEvent) |
||||||
|
.pipe(debounceTime(250)) |
||||||
|
.subscribe({ |
||||||
|
next: (evt) => { |
||||||
|
if (panelContext.eventBus === evt.origin) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setRowHighlightIndex(undefined); |
||||||
|
}, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
return () => { |
||||||
|
subs.unsubscribe(); |
||||||
|
}; |
||||||
|
}, [data, enableSharedCrosshair, footerPaginationEnabled, onDataHoverEvent, panelContext]); |
||||||
|
|
||||||
|
let scrollTop: number | undefined = undefined; |
||||||
|
if (rowHighlightIndex !== undefined) { |
||||||
|
const firstMatchedRowIndex = rows.findIndex((row) => row.index === rowHighlightIndex); |
||||||
|
|
||||||
|
if (firstMatchedRowIndex !== -1) { |
||||||
|
scrollTop = headerHeight + (firstMatchedRowIndex - 1) * rowHeight; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const rowIndexForPagination = useCallback( |
||||||
|
(index: number) => { |
||||||
|
return tableState.pageIndex * tableState.pageSize + index; |
||||||
|
}, |
||||||
|
[tableState.pageIndex, tableState.pageSize] |
||||||
|
); |
||||||
|
|
||||||
|
const RenderRow = useCallback( |
||||||
|
({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => { |
||||||
|
const indexForPagination = rowIndexForPagination(index); |
||||||
|
const row = rows[indexForPagination]; |
||||||
|
|
||||||
|
prepareRow(row); |
||||||
|
|
||||||
|
const expandedRowStyle = tableState.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {}; |
||||||
|
|
||||||
|
if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) { |
||||||
|
style = { ...style, backgroundColor: theme.components.table.rowHoverBackground }; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
{...row.getRowProps({ style })} |
||||||
|
className={cx(tableStyles.row, expandedRowStyle)} |
||||||
|
onMouseEnter={() => onRowHover(index, data)} |
||||||
|
onMouseLeave={onRowLeave} |
||||||
|
> |
||||||
|
{/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} |
||||||
|
{nestedDataField && tableState.expanded[row.index] && ( |
||||||
|
<ExpandedRow |
||||||
|
nestedData={nestedDataField} |
||||||
|
tableStyles={tableStyles} |
||||||
|
rowIndex={index} |
||||||
|
width={width} |
||||||
|
cellHeight={cellHeight} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{row.cells.map((cell: Cell, index: number) => ( |
||||||
|
<TableCell |
||||||
|
key={index} |
||||||
|
tableStyles={tableStyles} |
||||||
|
cell={cell} |
||||||
|
onCellFilterAdded={onCellFilterAdded} |
||||||
|
columnIndex={index} |
||||||
|
columnCount={row.cells.length} |
||||||
|
timeRange={timeRange} |
||||||
|
frame={data} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}, |
||||||
|
[ |
||||||
|
cellHeight, |
||||||
|
data, |
||||||
|
nestedDataField, |
||||||
|
onCellFilterAdded, |
||||||
|
onRowHover, |
||||||
|
onRowLeave, |
||||||
|
prepareRow, |
||||||
|
rowIndexForPagination, |
||||||
|
rows, |
||||||
|
tableState.expanded, |
||||||
|
tableStyles, |
||||||
|
theme.components.table.rowHoverBackground, |
||||||
|
timeRange, |
||||||
|
width, |
||||||
|
] |
||||||
|
); |
||||||
|
|
||||||
|
const getItemSize = (index: number): number => { |
||||||
|
const indexForPagination = rowIndexForPagination(index); |
||||||
|
const row = rows[indexForPagination]; |
||||||
|
if (tableState.expanded[row.index] && nestedDataField) { |
||||||
|
return getExpandedRowHeight(nestedDataField, index, tableStyles); |
||||||
|
} |
||||||
|
|
||||||
|
return tableStyles.rowHeight; |
||||||
|
}; |
||||||
|
|
||||||
|
const handleScroll: UIEventHandler = (event) => { |
||||||
|
const { scrollTop } = event.currentTarget; |
||||||
|
|
||||||
|
if (listRef.current !== null) { |
||||||
|
listRef.current.scrollTo(scrollTop); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true} scrollTop={scrollTop}> |
||||||
|
<VariableSizeList |
||||||
|
// This component needs an unmount/remount when row height or page changes
|
||||||
|
key={rowHeight + pageIndex} |
||||||
|
height={listHeight} |
||||||
|
itemCount={itemCount} |
||||||
|
itemSize={getItemSize} |
||||||
|
width={'100%'} |
||||||
|
ref={listRef} |
||||||
|
style={{ overflow: undefined }} |
||||||
|
> |
||||||
|
{({ index, style }) => RenderRow({ index, style, rowHighlightIndex })} |
||||||
|
</VariableSizeList> |
||||||
|
</CustomScrollbar> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
|
Loading…
Reference in new issue