import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useCallback, useState, CSSProperties } from 'react'; import * as React from 'react'; import { useTable, Column, TableOptions, Cell } from 'react-table'; import { FixedSizeList } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; import { Observable } from 'rxjs'; import { Field, GrafanaTheme2 } from '@grafana/data'; import { TableCellHeight } from '@grafana/schema'; import { useStyles2, useTheme2 } from '@grafana/ui'; import { TableCell } from '@grafana/ui/src/components/Table/TableCell'; import { useTableStyles } from '@grafana/ui/src/components/Table/styles'; import { useCustomFlexLayout } from 'app/features/browse-dashboards/components/customFlexTableLayout'; import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection'; import { QueryResponse } from '../../service/types'; import { SelectionChecker, SelectionToggle } from '../selection'; import { generateColumns } from './columns'; export type SearchResultsProps = { response: QueryResponse; width: number; height: number; selection?: SelectionChecker; selectionToggle?: SelectionToggle; clearSelection: () => void; onTagSelected: (tag: string) => void; onDatasourceChange?: (datasource?: string) => void; onClickItem?: (event: React.MouseEvent) => void; keyboardEvents: Observable; }; export type TableColumn = Column & { field?: Field; }; const ROW_HEIGHT = 36; // pixels export const SearchResultsTable = React.memo( ({ response, width, height, selection, selectionToggle, clearSelection, onTagSelected, onDatasourceChange, onClickItem, keyboardEvents, }: SearchResultsProps) => { const styles = useStyles2(getStyles); const columnStyles = useStyles2(getColumnStyles); const tableStyles = useTableStyles(useTheme2(), TableCellHeight.Sm); const infiniteLoaderRef = useRef(null); const [listEl, setListEl] = useState(null); const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response); const memoizedData = useMemo(() => { if (!response?.view?.dataFrame.fields.length) { return []; } // as we only use this to fake the length of our data set for react-table we need to make sure we always return an array // filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in // https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585 return Array(response.totalRows).fill(0); }, [response]); // Scroll to the top and clear loader cache when the query results change useEffect(() => { if (infiniteLoaderRef.current) { infiniteLoaderRef.current.resetloadMoreItemsCache(); } if (listEl) { listEl.scrollTo(0); } }, [memoizedData, listEl]); // React-table column definitions const memoizedColumns = useMemo(() => { return generateColumns( response, width, selection, selectionToggle, clearSelection, columnStyles, onTagSelected, onDatasourceChange, response.view?.length >= response.totalRows ); }, [response, width, columnStyles, selection, selectionToggle, clearSelection, onTagSelected, onDatasourceChange]); const options: TableOptions<{}> = useMemo( () => ({ columns: memoizedColumns, data: memoizedData, }), [memoizedColumns, memoizedData] ); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useCustomFlexLayout); const handleLoadMore = useCallback( async (startIndex: number, endIndex: number) => { await response.loadMoreItems(startIndex, endIndex); // After we load more items, select them if the "select all" checkbox // is selected const isAllSelected = selection?.('*', '*'); if (!selectionToggle || !selection || !isAllSelected) { return; } for (let index = startIndex; index < response.view.length; index++) { const item = response.view.get(index); const itemIsSelected = selection(item.kind, item.uid); if (!itemIsSelected) { selectionToggle(item.kind, item.uid); } } }, [response, selection, selectionToggle] ); const RenderRow = useCallback( ({ index: rowIndex, style }: { index: number; style: CSSProperties }) => { const row = rows[rowIndex]; prepareRow(row); const url = response.view.fields.url?.values[rowIndex]; let className = styles.rowContainer; if (rowIndex === highlightIndex.y) { className += ' ' + styles.selectedRow; } const { key, ...rowProps } = row.getRowProps({ style }); return (
{row.cells.map((cell: Cell, index: number) => { return ( ); })}
); }, [ rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles, onClickItem, response.view.dataFrame, ] ); if (!rows.length) { return
No data
; } return (
{headerGroups.map((headerGroup) => { const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps({ style: { width }, }); return (
{headerGroup.headers.map((column) => { const { key, ...headerProps } = column.getHeaderProps(); return (
{column.render('Header')}
); })}
); })}
{({ onItemsRendered, ref }) => ( { ref(innerRef); setListEl(innerRef); }} onItemsRendered={onItemsRendered} height={height - ROW_HEIGHT} itemCount={rows.length} itemSize={tableStyles.rowHeight} width={width} style={{ overflow: 'hidden auto' }} > {RenderRow} )}
); } ); SearchResultsTable.displayName = 'SearchResultsTable'; const getStyles = (theme: GrafanaTheme2) => { const rowHoverBg = theme.colors.action.hover; return { noData: css({ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', }), headerCell: css({ alignItems: 'center', display: 'flex', overflo: 'hidden', padding: theme.spacing(1), }), headerRow: css({ backgroundColor: theme.colors.background.secondary, display: 'flex', gap: theme.spacing(1), height: `${ROW_HEIGHT}px`, }), selectedRow: css({ backgroundColor: rowHoverBg, boxShadow: `inset 3px 0px ${theme.colors.primary.border}`, }), rowContainer: css({ display: 'flex', gap: theme.spacing(1), height: `${ROW_HEIGHT}px`, label: 'row', '&:hover': { backgroundColor: rowHoverBg, }, "&:not(:hover) div[role='cell']": { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }, }), }; }; // CSS for columns from react table const getColumnStyles = (theme: GrafanaTheme2) => { return { cell: css({ padding: theme.spacing(1), overflow: 'hidden', // Required so flex children can do text-overflow: ellipsis display: 'flex', alignItems: 'center', }), nameCellStyle: css({ overflow: 'hidden', textOverflow: 'ellipsis', userSelect: 'text', whiteSpace: 'nowrap', }), typeCell: css({ gap: theme.spacing(0.5), }), typeIcon: css({ fill: theme.colors.text.secondary, }), datasourceItem: css({ span: { '&:hover': { color: theme.colors.text.link, }, }, }), missingTitleText: css({ color: theme.colors.text.disabled, fontStyle: 'italic', }), invalidDatasourceItem: css({ color: theme.colors.error.main, textDecoration: 'line-through', }), locationContainer: css({ display: 'flex', flexWrap: 'nowrap', gap: theme.spacing(1), overflow: 'hidden', }), locationItem: css({ alignItems: 'center', color: theme.colors.text.secondary, display: 'flex', flexWrap: 'nowrap', gap: '4px', overflow: 'hidden', }), explainItem: css({ cursor: 'pointer', }), tagList: css({ justifyContent: 'flex-start', flexWrap: 'nowrap', }), }; };