SearchResultsTable.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. /* eslint-disable react/jsx-no-undef */
  2. import { css } from '@emotion/css';
  3. import React, { useEffect, useMemo, useRef, useCallback } from 'react';
  4. import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table';
  5. import { FixedSizeList } from 'react-window';
  6. import InfiniteLoader from 'react-window-infinite-loader';
  7. import { Observable } from 'rxjs';
  8. import { Field, GrafanaTheme2 } from '@grafana/data';
  9. import { useStyles2 } from '@grafana/ui';
  10. import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
  11. import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
  12. import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
  13. import { QueryResponse } from '../../service';
  14. import { SelectionChecker, SelectionToggle } from '../selection';
  15. import { generateColumns } from './columns';
  16. export type SearchResultsProps = {
  17. response: QueryResponse;
  18. width: number;
  19. height: number;
  20. selection?: SelectionChecker;
  21. selectionToggle?: SelectionToggle;
  22. clearSelection: () => void;
  23. onTagSelected: (tag: string) => void;
  24. onDatasourceChange?: (datasource?: string) => void;
  25. keyboardEvents: Observable<React.KeyboardEvent>;
  26. };
  27. export type TableColumn = Column & {
  28. field?: Field;
  29. };
  30. const HEADER_HEIGHT = 36; // pixels
  31. export const SearchResultsTable = React.memo(
  32. ({
  33. response,
  34. width,
  35. height,
  36. selection,
  37. selectionToggle,
  38. clearSelection,
  39. onTagSelected,
  40. onDatasourceChange,
  41. keyboardEvents,
  42. }: SearchResultsProps) => {
  43. const styles = useStyles2(getStyles);
  44. const tableStyles = useStyles2(getTableStyles);
  45. const infiniteLoaderRef = useRef<InfiniteLoader>(null);
  46. const listRef = useRef<FixedSizeList>(null);
  47. const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response);
  48. const memoizedData = useMemo(() => {
  49. if (!response?.view?.dataFrame.fields.length) {
  50. return [];
  51. }
  52. // 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
  53. // filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in
  54. // https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585
  55. return Array(response.totalRows).fill(0);
  56. }, [response]);
  57. // Scroll to the top and clear loader cache when the query results change
  58. useEffect(() => {
  59. if (infiniteLoaderRef.current) {
  60. infiniteLoaderRef.current.resetloadMoreItemsCache();
  61. }
  62. if (listRef.current) {
  63. listRef.current.scrollTo(0);
  64. }
  65. }, [memoizedData]);
  66. // React-table column definitions
  67. const memoizedColumns = useMemo(() => {
  68. return generateColumns(
  69. response,
  70. width,
  71. selection,
  72. selectionToggle,
  73. clearSelection,
  74. styles,
  75. onTagSelected,
  76. onDatasourceChange
  77. );
  78. }, [response, width, styles, selection, selectionToggle, clearSelection, onTagSelected, onDatasourceChange]);
  79. const options: TableOptions<{}> = useMemo(
  80. () => ({
  81. columns: memoizedColumns,
  82. data: memoizedData,
  83. }),
  84. [memoizedColumns, memoizedData]
  85. );
  86. const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout);
  87. const RenderRow = useCallback(
  88. ({ index: rowIndex, style }) => {
  89. const row = rows[rowIndex];
  90. prepareRow(row);
  91. const url = response.view.fields.url?.values.get(rowIndex);
  92. let className = styles.rowContainer;
  93. if (rowIndex === highlightIndex.y) {
  94. className += ' ' + styles.selectedRow;
  95. }
  96. return (
  97. <div {...row.getRowProps({ style })} className={className}>
  98. {row.cells.map((cell: Cell, index: number) => {
  99. return (
  100. <TableCell
  101. key={index}
  102. tableStyles={tableStyles}
  103. cell={cell}
  104. columnIndex={index}
  105. columnCount={row.cells.length}
  106. userProps={{ href: url }}
  107. />
  108. );
  109. })}
  110. </div>
  111. );
  112. },
  113. [rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles]
  114. );
  115. if (!rows.length) {
  116. return <div className={styles.noData}>No data</div>;
  117. }
  118. return (
  119. <div {...getTableProps()} aria-label="Search result table" role="table">
  120. <div>
  121. {headerGroups.map((headerGroup) => {
  122. const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
  123. return (
  124. <div key={key} {...headerGroupProps} className={styles.headerRow}>
  125. {headerGroup.headers.map((column) => {
  126. const { key, ...headerProps } = column.getHeaderProps();
  127. return (
  128. <div key={key} {...headerProps} role="columnheader" className={styles.headerCell}>
  129. {column.render('Header')}
  130. </div>
  131. );
  132. })}
  133. </div>
  134. );
  135. })}
  136. </div>
  137. <div {...getTableBodyProps()}>
  138. <InfiniteLoader
  139. ref={infiniteLoaderRef}
  140. isItemLoaded={response.isItemLoaded}
  141. itemCount={rows.length}
  142. loadMoreItems={response.loadMoreItems}
  143. >
  144. {({ onItemsRendered }) => (
  145. <FixedSizeList
  146. ref={listRef}
  147. onItemsRendered={onItemsRendered}
  148. height={height - HEADER_HEIGHT}
  149. itemCount={rows.length}
  150. itemSize={tableStyles.rowHeight}
  151. width="100%"
  152. style={{ overflow: 'hidden auto' }}
  153. >
  154. {RenderRow}
  155. </FixedSizeList>
  156. )}
  157. </InfiniteLoader>
  158. </div>
  159. </div>
  160. );
  161. }
  162. );
  163. SearchResultsTable.displayName = 'SearchResultsTable';
  164. const getStyles = (theme: GrafanaTheme2) => {
  165. const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03);
  166. return {
  167. noData: css`
  168. display: flex;
  169. flex-direction: column;
  170. align-items: center;
  171. justify-content: center;
  172. height: 100%;
  173. `,
  174. table: css`
  175. width: 100%;
  176. `,
  177. cellIcon: css`
  178. display: flex;
  179. align-items: center;
  180. `,
  181. nameCellStyle: css`
  182. border-right: none;
  183. padding: ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(2)};
  184. overflow: hidden;
  185. text-overflow: ellipsis;
  186. user-select: text;
  187. white-space: nowrap;
  188. &:hover {
  189. box-shadow: none;
  190. }
  191. `,
  192. headerNameStyle: css`
  193. padding-left: ${theme.spacing(1)};
  194. `,
  195. headerCell: css`
  196. padding: ${theme.spacing(1)};
  197. `,
  198. headerRow: css`
  199. background-color: ${theme.colors.background.secondary};
  200. height: ${HEADER_HEIGHT}px;
  201. align-items: center;
  202. `,
  203. selectedRow: css`
  204. background-color: ${rowHoverBg};
  205. box-shadow: inset 3px 0px ${theme.colors.primary.border};
  206. `,
  207. rowContainer: css`
  208. label: row;
  209. &:hover {
  210. background-color: ${rowHoverBg};
  211. }
  212. &:not(:hover) div[role='cell'] {
  213. white-space: nowrap;
  214. overflow: hidden;
  215. text-overflow: ellipsis;
  216. }
  217. `,
  218. typeIcon: css`
  219. margin-left: 5px;
  220. margin-right: 9.5px;
  221. vertical-align: middle;
  222. display: inline-block;
  223. margin-bottom: ${theme.v1.spacing.xxs};
  224. fill: ${theme.colors.text.secondary};
  225. `,
  226. datasourceItem: css`
  227. span {
  228. &:hover {
  229. color: ${theme.colors.text.link};
  230. }
  231. }
  232. `,
  233. missingTitleText: css`
  234. color: ${theme.colors.text.disabled};
  235. font-style: italic;
  236. `,
  237. invalidDatasourceItem: css`
  238. color: ${theme.colors.error.main};
  239. text-decoration: line-through;
  240. `,
  241. typeText: css`
  242. color: ${theme.colors.text.secondary};
  243. padding-top: ${theme.spacing(1)};
  244. `,
  245. locationItem: css`
  246. color: ${theme.colors.text.secondary};
  247. margin-right: 12px;
  248. `,
  249. sortedHeader: css`
  250. text-align: right;
  251. padding-right: ${theme.spacing(2)};
  252. `,
  253. sortedItems: css`
  254. text-align: right;
  255. padding: ${theme.spacing(1)} ${theme.spacing(3)} ${theme.spacing(1)} ${theme.spacing(1)};
  256. `,
  257. locationCellStyle: css`
  258. padding-top: ${theme.spacing(1)};
  259. padding-right: ${theme.spacing(1)};
  260. `,
  261. checkboxHeader: css`
  262. margin-left: 2px;
  263. `,
  264. checkbox: css`
  265. margin-left: 10px;
  266. margin-right: 10px;
  267. margin-top: 5px;
  268. `,
  269. infoWrap: css`
  270. color: ${theme.colors.text.secondary};
  271. span {
  272. margin-right: 10px;
  273. }
  274. `,
  275. tagList: css`
  276. padding-top: ${theme.spacing(0.5)};
  277. justify-content: flex-start;
  278. flex-wrap: nowrap;
  279. `,
  280. };
  281. };