Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Table: Highlight row on shared crosshair #78392

Merged
merged 21 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Experimental features might be changed or removed without prior notice.
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected |
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
| `regressionTransformation` | Enables regression analysis transformation |

## Development feature toggles
Expand Down
8 changes: 7 additions & 1 deletion packages/grafana-data/src/dataframe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,11 @@ export * from './dimensions';
export * from './ArrayDataFrame';
export * from './DataFrameJSON';
export * from './frameComparisons';
export { anySeriesWithTimeField, isTimeSeriesFrame, isTimeSeriesFrames, isTimeSeriesField } from './utils';
export {
anySeriesWithTimeField,
hasTimeField,
isTimeSeriesFrame,
isTimeSeriesFrames,
isTimeSeriesField,
} from './utils';
export { StreamingDataFrame, StreamingFrameAction, type StreamingFrameOptions, closestIdx } from './StreamingDataFrame';
8 changes: 8 additions & 0 deletions packages/grafana-data/src/dataframe/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,11 @@ export function anySeriesWithTimeField(data: DataFrame[]) {
}
return false;
}

/**
* Indicates if there is any time field in the data frame
* @param data
*/
export function hasTimeField(data: DataFrame): boolean {
return data.fields.some((field) => field.type === FieldType.time);
}
1 change: 1 addition & 0 deletions packages/grafana-data/src/types/featureToggles.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface FeatureToggles {
alertingSimplifiedRouting?: boolean;
logRowsPopoverMenu?: boolean;
pluginsSkipHostEnvVars?: boolean;
tableSharedCrosshair?: boolean;
regressionTransformation?: boolean;
displayAnonymousStats?: boolean;
alertStateHistoryAnnotationsFromLoki?: boolean;
Expand Down
301 changes: 301 additions & 0 deletions packages/grafana-ui/src/components/Table/RowsList.tsx
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};