grafana/public/app/features/search/page/components/SearchResultsTable.tsx

330 lines
9.8 KiB
TypeScript

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 { useTableStyles, TableCell } from '@grafana/ui/internal';
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<HTMLElement>) => void;
keyboardEvents: Observable<React.KeyboardEvent>;
};
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<InfiniteLoader>(null);
const [listEl, setListEl] = useState<FixedSizeList | null>(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 (
<div key={key} {...rowProps} className={className}>
{row.cells.map((cell: Cell, index: number) => {
return (
<TableCell
key={index}
tableStyles={tableStyles}
cell={cell}
columnIndex={index}
columnCount={row.cells.length}
userProps={{ href: url, onClick: onClickItem }}
frame={response.view.dataFrame}
/>
);
})}
</div>
);
},
[
rows,
prepareRow,
response.view.fields.url?.values,
highlightIndex,
styles,
tableStyles,
onClickItem,
response.view.dataFrame,
]
);
if (!rows.length) {
return <div className={styles.noData}>No data</div>;
}
return (
<div {...getTableProps()} aria-label="Search results table" role="table">
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps({
style: { width },
});
return (
<div key={key} {...headerGroupProps} className={styles.headerRow}>
{headerGroup.headers.map((column) => {
const { key, ...headerProps } = column.getHeaderProps();
return (
<div key={key} {...headerProps} role="columnheader" className={styles.headerCell}>
{column.render('Header')}
</div>
);
})}
</div>
);
})}
<div {...getTableBodyProps()}>
<InfiniteLoader
ref={infiniteLoaderRef}
isItemLoaded={response.isItemLoaded}
itemCount={rows.length}
loadMoreItems={handleLoadMore}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
ref={(innerRef) => {
ref(innerRef);
setListEl(innerRef);
}}
onItemsRendered={onItemsRendered}
height={height - ROW_HEIGHT}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={width}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
)}
</InfiniteLoader>
</div>
</div>
);
}
);
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',
}),
};
};