index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import {Component, createRef, Fragment, Profiler, ReactNode} from 'react';
  2. import {Location} from 'history';
  3. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  4. import LoadingIndicator from 'sentry/components/loadingIndicator';
  5. import {IconWarning} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import {onRenderCallback} from 'sentry/utils/performanceForSentry';
  8. import {
  9. Body,
  10. Grid,
  11. GridBody,
  12. GridBodyCell,
  13. GridBodyCellStatus,
  14. GridHead,
  15. GridHeadCell,
  16. GridHeadCellStatic,
  17. GridResizer,
  18. GridRow,
  19. Header,
  20. HeaderButtonContainer,
  21. HeaderTitle,
  22. } from './styles';
  23. // Auto layout width.
  24. export const COL_WIDTH_UNDEFINED = -1;
  25. // Set to 90 as the edit/trash icons need this much space.
  26. export const COL_WIDTH_MINIMUM = 90;
  27. // For GridEditable, there are 2 generic types for the component, T and K
  28. //
  29. // - T is an element/object that represents the data to be displayed
  30. // - K is a key of T/
  31. // - columnKey should have the same set of values as K
  32. type ObjectKey = React.ReactText;
  33. export type GridColumn<K = ObjectKey> = {
  34. key: K;
  35. width?: number;
  36. };
  37. export type GridColumnHeader<K = ObjectKey> = GridColumn<K> & {
  38. name: string;
  39. };
  40. export type GridColumnOrder<K = ObjectKey> = GridColumnHeader<K>;
  41. export type GridColumnSortBy<K = ObjectKey> = GridColumn<K> & {
  42. order: 'desc' | 'asc';
  43. };
  44. /**
  45. * Store state at the start of "resize" action
  46. */
  47. export type ColResizeMetadata = {
  48. columnIndex: number; // Column being resized
  49. columnWidth: number; // Column width at start of resizing
  50. cursorX: number; // X-coordinate of cursor on window
  51. };
  52. type GridEditableProps<DataRow, ColumnKey> = {
  53. columnOrder: GridColumnOrder<ColumnKey>[];
  54. columnSortBy: GridColumnSortBy<ColumnKey>[];
  55. data: DataRow[];
  56. /**
  57. * GridEditable allows the parent component to determine how to display the
  58. * data within it. Note that this is optional.
  59. */
  60. grid: {
  61. onResizeColumn?: (
  62. columnIndex: number,
  63. nextColumn: GridColumnOrder<ColumnKey>
  64. ) => void;
  65. prependColumnWidths?: string[];
  66. renderBodyCell?: (
  67. column: GridColumnOrder<ColumnKey>,
  68. dataRow: DataRow,
  69. rowIndex: number,
  70. columnIndex: number
  71. ) => React.ReactNode;
  72. renderHeadCell?: (
  73. column: GridColumnOrder<ColumnKey>,
  74. columnIndex: number
  75. ) => React.ReactNode;
  76. renderPrependColumns?: (
  77. isHeader: boolean,
  78. dataRow?: DataRow,
  79. rowIndex?: number
  80. ) => React.ReactNode[];
  81. };
  82. location: Location;
  83. emptyMessage?: React.ReactNode;
  84. error?: React.ReactNode | null;
  85. /**
  86. * Inject a set of buttons into the top of the grid table.
  87. * The controlling component is responsible for handling any actions
  88. * in these buttons and updating props to the GridEditable instance.
  89. */
  90. headerButtons?: () => React.ReactNode;
  91. height?: string | number;
  92. isLoading?: boolean;
  93. scrollable?: boolean;
  94. stickyHeader?: boolean;
  95. /**
  96. * GridEditable (mostly) do not maintain any internal state and relies on the
  97. * parent component to tell it how/what to render and will mutate the view
  98. * based on this 3 main props.
  99. *
  100. * - `columnOrder` determines the columns to show, from left to right
  101. * - `columnSortBy` is not used at the moment, however it might be better to
  102. * move sorting into Grid for performance
  103. */
  104. title?: ReactNode;
  105. };
  106. type GridEditableState = {
  107. numColumn: number;
  108. };
  109. class GridEditable<
  110. DataRow extends {[key: string]: any},
  111. ColumnKey extends ObjectKey,
  112. > extends Component<GridEditableProps<DataRow, ColumnKey>, GridEditableState> {
  113. // Static methods do not allow the use of generics bounded to the parent class
  114. // For more info: https://github.com/microsoft/TypeScript/issues/14600
  115. static getDerivedStateFromProps(
  116. props: Readonly<GridEditableProps<Record<string, any>, ObjectKey>>,
  117. prevState: GridEditableState
  118. ): GridEditableState {
  119. return {
  120. ...prevState,
  121. numColumn: props.columnOrder.length,
  122. };
  123. }
  124. state: GridEditableState = {
  125. numColumn: 0,
  126. };
  127. componentDidMount() {
  128. window.addEventListener('resize', this.redrawGridColumn);
  129. this.setGridTemplateColumns(this.props.columnOrder);
  130. }
  131. componentDidUpdate() {
  132. // Redraw columns whenever new props are received
  133. this.setGridTemplateColumns(this.props.columnOrder);
  134. }
  135. componentWillUnmount() {
  136. this.clearWindowLifecycleEvents();
  137. window.removeEventListener('resize', this.redrawGridColumn);
  138. }
  139. private refGrid = createRef<HTMLTableElement>();
  140. private resizeMetadata?: ColResizeMetadata;
  141. private resizeWindowLifecycleEvents: {
  142. [eventName: string]: any[];
  143. } = {
  144. mousemove: [],
  145. mouseup: [],
  146. };
  147. clearWindowLifecycleEvents() {
  148. Object.keys(this.resizeWindowLifecycleEvents).forEach(e => {
  149. this.resizeWindowLifecycleEvents[e].forEach(c => window.removeEventListener(e, c));
  150. this.resizeWindowLifecycleEvents[e] = [];
  151. });
  152. }
  153. onResetColumnSize = (e: React.MouseEvent, i: number) => {
  154. e.stopPropagation();
  155. const nextColumnOrder = [...this.props.columnOrder];
  156. nextColumnOrder[i] = {
  157. ...nextColumnOrder[i],
  158. width: COL_WIDTH_UNDEFINED,
  159. };
  160. this.setGridTemplateColumns(nextColumnOrder);
  161. const onResizeColumn = this.props.grid.onResizeColumn;
  162. if (onResizeColumn) {
  163. onResizeColumn(i, {
  164. ...nextColumnOrder[i],
  165. width: COL_WIDTH_UNDEFINED,
  166. });
  167. }
  168. };
  169. onResizeMouseDown = (e: React.MouseEvent, i: number = -1) => {
  170. e.stopPropagation();
  171. // Block right-click and other funky stuff
  172. if (i === -1 || e.type === 'contextmenu') {
  173. return;
  174. }
  175. // <GridResizer> is nested 1 level down from <GridHeadCell>
  176. const cell = e.currentTarget!.parentElement;
  177. if (!cell) {
  178. return;
  179. }
  180. // HACK: Do not put into state to prevent re-rendering of component
  181. this.resizeMetadata = {
  182. columnIndex: i,
  183. columnWidth: cell.offsetWidth,
  184. cursorX: e.clientX,
  185. };
  186. window.addEventListener('mousemove', this.onResizeMouseMove);
  187. this.resizeWindowLifecycleEvents.mousemove.push(this.onResizeMouseMove);
  188. window.addEventListener('mouseup', this.onResizeMouseUp);
  189. this.resizeWindowLifecycleEvents.mouseup.push(this.onResizeMouseUp);
  190. };
  191. onResizeMouseUp = (e: MouseEvent) => {
  192. const metadata = this.resizeMetadata;
  193. const onResizeColumn = this.props.grid.onResizeColumn;
  194. if (metadata && onResizeColumn) {
  195. const {columnOrder} = this.props;
  196. const widthChange = e.clientX - metadata.cursorX;
  197. onResizeColumn(metadata.columnIndex, {
  198. ...columnOrder[metadata.columnIndex],
  199. width: metadata.columnWidth + widthChange,
  200. });
  201. }
  202. this.resizeMetadata = undefined;
  203. this.clearWindowLifecycleEvents();
  204. };
  205. onResizeMouseMove = (e: MouseEvent) => {
  206. const {resizeMetadata} = this;
  207. if (!resizeMetadata) {
  208. return;
  209. }
  210. window.requestAnimationFrame(() => this.resizeGridColumn(e, resizeMetadata));
  211. };
  212. resizeGridColumn(e: MouseEvent, metadata: ColResizeMetadata) {
  213. const grid = this.refGrid.current;
  214. if (!grid) {
  215. return;
  216. }
  217. const widthChange = e.clientX - metadata.cursorX;
  218. const nextColumnOrder = [...this.props.columnOrder];
  219. nextColumnOrder[metadata.columnIndex] = {
  220. ...nextColumnOrder[metadata.columnIndex],
  221. width: Math.max(metadata.columnWidth + widthChange, 0),
  222. };
  223. this.setGridTemplateColumns(nextColumnOrder);
  224. }
  225. /**
  226. * Recalculate the dimensions of Grid and Columns and redraws them
  227. */
  228. redrawGridColumn = () => {
  229. this.setGridTemplateColumns(this.props.columnOrder);
  230. };
  231. /**
  232. * Set the CSS for Grid Column
  233. */
  234. setGridTemplateColumns(columnOrder: GridColumnOrder[]) {
  235. const grid = this.refGrid.current;
  236. if (!grid) {
  237. return;
  238. }
  239. const prependColumns = this.props.grid.prependColumnWidths || [];
  240. const prepend = prependColumns.join(' ');
  241. const widths = columnOrder.map((item, index) => {
  242. if (item.width === COL_WIDTH_UNDEFINED) {
  243. return `minmax(${COL_WIDTH_MINIMUM}px, auto)`;
  244. }
  245. if (typeof item.width === 'number' && item.width > COL_WIDTH_MINIMUM) {
  246. if (index === columnOrder.length - 1) {
  247. return `minmax(${item.width}px, auto)`;
  248. }
  249. return `${item.width}px`;
  250. }
  251. if (index === columnOrder.length - 1) {
  252. return `minmax(${COL_WIDTH_MINIMUM}px, auto)`;
  253. }
  254. return `${COL_WIDTH_MINIMUM}px`;
  255. });
  256. // The last column has no resizer and should always be a flexible column
  257. // to prevent underflows.
  258. grid.style.gridTemplateColumns = `${prepend} ${widths.join(' ')}`;
  259. }
  260. renderGridHead() {
  261. const {error, isLoading, columnOrder, grid, data, stickyHeader} = this.props;
  262. // Ensure that the last column cannot be removed
  263. const numColumn = columnOrder.length;
  264. const prependColumns = grid.renderPrependColumns
  265. ? grid.renderPrependColumns(true)
  266. : [];
  267. return (
  268. <GridRow data-test-id="grid-head-row">
  269. {prependColumns &&
  270. columnOrder?.length > 0 &&
  271. prependColumns.map((item, i) => (
  272. <GridHeadCellStatic data-test-id="grid-head-cell-static" key={`prepend-${i}`}>
  273. {item}
  274. </GridHeadCellStatic>
  275. ))}
  276. {
  277. // Note that this.onResizeMouseDown assumes GridResizer is nested
  278. // 1 levels under GridHeadCell
  279. columnOrder.map((column, i) => (
  280. <GridHeadCell
  281. data-test-id="grid-head-cell"
  282. key={`${i}.${column.key}`}
  283. isFirst={i === 0}
  284. sticky={stickyHeader}
  285. >
  286. {grid.renderHeadCell ? grid.renderHeadCell(column, i) : column.name}
  287. {i !== numColumn - 1 && (
  288. <GridResizer
  289. dataRows={!error && !isLoading && data ? data.length : 0}
  290. onMouseDown={e => this.onResizeMouseDown(e, i)}
  291. onDoubleClick={e => this.onResetColumnSize(e, i)}
  292. onContextMenu={this.onResizeMouseDown}
  293. />
  294. )}
  295. </GridHeadCell>
  296. ))
  297. }
  298. </GridRow>
  299. );
  300. }
  301. renderGridBody() {
  302. const {data, error, isLoading} = this.props;
  303. if (error) {
  304. return this.renderError();
  305. }
  306. if (isLoading) {
  307. return this.renderLoading();
  308. }
  309. if (!data || data.length === 0) {
  310. return this.renderEmptyData();
  311. }
  312. return data.map(this.renderGridBodyRow);
  313. }
  314. renderGridBodyRow = (dataRow: DataRow, row: number) => {
  315. const {columnOrder, grid} = this.props;
  316. const prependColumns = grid.renderPrependColumns
  317. ? grid.renderPrependColumns(false, dataRow, row)
  318. : [];
  319. return (
  320. <GridRow key={row} data-test-id="grid-body-row">
  321. {prependColumns &&
  322. prependColumns.map((item, i) => (
  323. <GridBodyCell data-test-id="grid-body-cell" key={`prepend-${i}`}>
  324. {item}
  325. </GridBodyCell>
  326. ))}
  327. {columnOrder.map((col, i) => (
  328. <GridBodyCell data-test-id="grid-body-cell" key={`${col.key}${i}`}>
  329. {grid.renderBodyCell
  330. ? grid.renderBodyCell(col, dataRow, row, i)
  331. : dataRow[col.key]}
  332. </GridBodyCell>
  333. ))}
  334. </GridRow>
  335. );
  336. };
  337. renderError() {
  338. return (
  339. <GridRow>
  340. <GridBodyCellStatus>
  341. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  342. </GridBodyCellStatus>
  343. </GridRow>
  344. );
  345. }
  346. renderLoading() {
  347. return (
  348. <GridRow>
  349. <GridBodyCellStatus>
  350. <LoadingIndicator />
  351. </GridBodyCellStatus>
  352. </GridRow>
  353. );
  354. }
  355. renderEmptyData() {
  356. const {emptyMessage} = this.props;
  357. return (
  358. <GridRow>
  359. <GridBodyCellStatus>
  360. {emptyMessage ?? (
  361. <EmptyStateWarning>
  362. <p>{t('No results found for your query')}</p>
  363. </EmptyStateWarning>
  364. )}
  365. </GridBodyCellStatus>
  366. </GridRow>
  367. );
  368. }
  369. render() {
  370. const {title, headerButtons, scrollable, height} = this.props;
  371. const showHeader = title || headerButtons;
  372. return (
  373. <Fragment>
  374. <Profiler id="GridEditable" onRender={onRenderCallback}>
  375. {showHeader && (
  376. <Header>
  377. {title && <HeaderTitle>{title}</HeaderTitle>}
  378. {headerButtons && (
  379. <HeaderButtonContainer>{headerButtons()}</HeaderButtonContainer>
  380. )}
  381. </Header>
  382. )}
  383. <Body>
  384. <Grid
  385. data-test-id="grid-editable"
  386. scrollable={scrollable}
  387. height={height}
  388. ref={this.refGrid}
  389. >
  390. <GridHead>{this.renderGridHead()}</GridHead>
  391. <GridBody>{this.renderGridBody()}</GridBody>
  392. </Grid>
  393. </Body>
  394. </Profiler>
  395. </Fragment>
  396. );
  397. }
  398. }
  399. export default GridEditable;