index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import {Component, createRef, Fragment, 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, Profiler} 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. minimumColWidth?: number;
  94. scrollable?: boolean;
  95. stickyHeader?: boolean;
  96. /**
  97. * GridEditable (mostly) do not maintain any internal state and relies on the
  98. * parent component to tell it how/what to render and will mutate the view
  99. * based on this 3 main props.
  100. *
  101. * - `columnOrder` determines the columns to show, from left to right
  102. * - `columnSortBy` is not used at the moment, however it might be better to
  103. * move sorting into Grid for performance
  104. */
  105. title?: ReactNode;
  106. };
  107. type GridEditableState = {
  108. numColumn: number;
  109. };
  110. class GridEditable<
  111. DataRow extends {[key: string]: any},
  112. ColumnKey extends ObjectKey,
  113. > extends Component<GridEditableProps<DataRow, ColumnKey>, GridEditableState> {
  114. // Static methods do not allow the use of generics bounded to the parent class
  115. // For more info: https://github.com/microsoft/TypeScript/issues/14600
  116. static getDerivedStateFromProps(
  117. props: Readonly<GridEditableProps<Record<string, any>, ObjectKey>>,
  118. prevState: GridEditableState
  119. ): GridEditableState {
  120. return {
  121. ...prevState,
  122. numColumn: props.columnOrder.length,
  123. };
  124. }
  125. state: GridEditableState = {
  126. numColumn: 0,
  127. };
  128. componentDidMount() {
  129. window.addEventListener('resize', this.redrawGridColumn);
  130. this.setGridTemplateColumns(this.props.columnOrder);
  131. }
  132. componentDidUpdate() {
  133. // Redraw columns whenever new props are received
  134. this.setGridTemplateColumns(this.props.columnOrder);
  135. }
  136. componentWillUnmount() {
  137. this.clearWindowLifecycleEvents();
  138. window.removeEventListener('resize', this.redrawGridColumn);
  139. }
  140. private refGrid = createRef<HTMLTableElement>();
  141. private resizeMetadata?: ColResizeMetadata;
  142. private resizeWindowLifecycleEvents: {
  143. [eventName: string]: any[];
  144. } = {
  145. mousemove: [],
  146. mouseup: [],
  147. };
  148. clearWindowLifecycleEvents() {
  149. Object.keys(this.resizeWindowLifecycleEvents).forEach(e => {
  150. this.resizeWindowLifecycleEvents[e].forEach(c => window.removeEventListener(e, c));
  151. this.resizeWindowLifecycleEvents[e] = [];
  152. });
  153. }
  154. onResetColumnSize = (e: React.MouseEvent, i: number) => {
  155. e.stopPropagation();
  156. const nextColumnOrder = [...this.props.columnOrder];
  157. nextColumnOrder[i] = {
  158. ...nextColumnOrder[i],
  159. width: COL_WIDTH_UNDEFINED,
  160. };
  161. this.setGridTemplateColumns(nextColumnOrder);
  162. const onResizeColumn = this.props.grid.onResizeColumn;
  163. if (onResizeColumn) {
  164. onResizeColumn(i, {
  165. ...nextColumnOrder[i],
  166. width: COL_WIDTH_UNDEFINED,
  167. });
  168. }
  169. };
  170. onResizeMouseDown = (e: React.MouseEvent, i: number = -1) => {
  171. e.stopPropagation();
  172. // Block right-click and other funky stuff
  173. if (i === -1 || e.type === 'contextmenu') {
  174. return;
  175. }
  176. // <GridResizer> is nested 1 level down from <GridHeadCell>
  177. const cell = e.currentTarget!.parentElement;
  178. if (!cell) {
  179. return;
  180. }
  181. // HACK: Do not put into state to prevent re-rendering of component
  182. this.resizeMetadata = {
  183. columnIndex: i,
  184. columnWidth: cell.offsetWidth,
  185. cursorX: e.clientX,
  186. };
  187. window.addEventListener('mousemove', this.onResizeMouseMove);
  188. this.resizeWindowLifecycleEvents.mousemove.push(this.onResizeMouseMove);
  189. window.addEventListener('mouseup', this.onResizeMouseUp);
  190. this.resizeWindowLifecycleEvents.mouseup.push(this.onResizeMouseUp);
  191. };
  192. onResizeMouseUp = (e: MouseEvent) => {
  193. const metadata = this.resizeMetadata;
  194. const onResizeColumn = this.props.grid.onResizeColumn;
  195. if (metadata && onResizeColumn) {
  196. const {columnOrder} = this.props;
  197. const widthChange = e.clientX - metadata.cursorX;
  198. onResizeColumn(metadata.columnIndex, {
  199. ...columnOrder[metadata.columnIndex],
  200. width: metadata.columnWidth + widthChange,
  201. });
  202. }
  203. this.resizeMetadata = undefined;
  204. this.clearWindowLifecycleEvents();
  205. };
  206. onResizeMouseMove = (e: MouseEvent) => {
  207. const {resizeMetadata} = this;
  208. if (!resizeMetadata) {
  209. return;
  210. }
  211. window.requestAnimationFrame(() => this.resizeGridColumn(e, resizeMetadata));
  212. };
  213. resizeGridColumn(e: MouseEvent, metadata: ColResizeMetadata) {
  214. const grid = this.refGrid.current;
  215. if (!grid) {
  216. return;
  217. }
  218. const widthChange = e.clientX - metadata.cursorX;
  219. const nextColumnOrder = [...this.props.columnOrder];
  220. nextColumnOrder[metadata.columnIndex] = {
  221. ...nextColumnOrder[metadata.columnIndex],
  222. width: Math.max(metadata.columnWidth + widthChange, 0),
  223. };
  224. this.setGridTemplateColumns(nextColumnOrder);
  225. }
  226. /**
  227. * Recalculate the dimensions of Grid and Columns and redraws them
  228. */
  229. redrawGridColumn = () => {
  230. this.setGridTemplateColumns(this.props.columnOrder);
  231. };
  232. /**
  233. * Set the CSS for Grid Column
  234. */
  235. setGridTemplateColumns(columnOrder: GridColumnOrder[]) {
  236. const grid = this.refGrid.current;
  237. if (!grid) {
  238. return;
  239. }
  240. const minimumColWidth = this.props.minimumColWidth ?? COL_WIDTH_MINIMUM;
  241. const prependColumns = this.props.grid.prependColumnWidths || [];
  242. const prepend = prependColumns.join(' ');
  243. const widths = columnOrder.map((item, index) => {
  244. if (item.width === COL_WIDTH_UNDEFINED) {
  245. return `minmax(${minimumColWidth}px, auto)`;
  246. }
  247. if (typeof item.width === 'number' && item.width > minimumColWidth) {
  248. if (index === columnOrder.length - 1) {
  249. return `minmax(${item.width}px, auto)`;
  250. }
  251. return `${item.width}px`;
  252. }
  253. if (index === columnOrder.length - 1) {
  254. return `minmax(${minimumColWidth}px, auto)`;
  255. }
  256. return `${minimumColWidth}px`;
  257. });
  258. // The last column has no resizer and should always be a flexible column
  259. // to prevent underflows.
  260. grid.style.gridTemplateColumns = `${prepend} ${widths.join(' ')}`;
  261. }
  262. renderGridHead() {
  263. const {error, isLoading, columnOrder, grid, data, stickyHeader} = this.props;
  264. // Ensure that the last column cannot be removed
  265. const numColumn = columnOrder.length;
  266. const prependColumns = grid.renderPrependColumns
  267. ? grid.renderPrependColumns(true)
  268. : [];
  269. return (
  270. <GridRow data-test-id="grid-head-row">
  271. {prependColumns &&
  272. columnOrder?.length > 0 &&
  273. prependColumns.map((item, i) => (
  274. <GridHeadCellStatic data-test-id="grid-head-cell-static" key={`prepend-${i}`}>
  275. {item}
  276. </GridHeadCellStatic>
  277. ))}
  278. {
  279. // Note that this.onResizeMouseDown assumes GridResizer is nested
  280. // 1 levels under GridHeadCell
  281. columnOrder.map((column, i) => (
  282. <GridHeadCell
  283. data-test-id="grid-head-cell"
  284. key={`${i}.${column.key}`}
  285. isFirst={i === 0}
  286. sticky={stickyHeader}
  287. >
  288. {grid.renderHeadCell ? grid.renderHeadCell(column, i) : column.name}
  289. {i !== numColumn - 1 && (
  290. <GridResizer
  291. dataRows={!error && !isLoading && data ? data.length : 0}
  292. onMouseDown={e => this.onResizeMouseDown(e, i)}
  293. onDoubleClick={e => this.onResetColumnSize(e, i)}
  294. onContextMenu={this.onResizeMouseDown}
  295. />
  296. )}
  297. </GridHeadCell>
  298. ))
  299. }
  300. </GridRow>
  301. );
  302. }
  303. renderGridBody() {
  304. const {data, error, isLoading} = this.props;
  305. if (error) {
  306. return this.renderError();
  307. }
  308. if (isLoading) {
  309. return this.renderLoading();
  310. }
  311. if (!data || data.length === 0) {
  312. return this.renderEmptyData();
  313. }
  314. return data.map(this.renderGridBodyRow);
  315. }
  316. renderGridBodyRow = (dataRow: DataRow, row: number) => {
  317. const {columnOrder, grid} = this.props;
  318. const prependColumns = grid.renderPrependColumns
  319. ? grid.renderPrependColumns(false, dataRow, row)
  320. : [];
  321. return (
  322. <GridRow key={row} data-test-id="grid-body-row">
  323. {prependColumns &&
  324. prependColumns.map((item, i) => (
  325. <GridBodyCell data-test-id="grid-body-cell" key={`prepend-${i}`}>
  326. {item}
  327. </GridBodyCell>
  328. ))}
  329. {columnOrder.map((col, i) => (
  330. <GridBodyCell data-test-id="grid-body-cell" key={`${col.key}${i}`}>
  331. {grid.renderBodyCell
  332. ? grid.renderBodyCell(col, dataRow, row, i)
  333. : dataRow[col.key]}
  334. </GridBodyCell>
  335. ))}
  336. </GridRow>
  337. );
  338. };
  339. renderError() {
  340. return (
  341. <GridRow>
  342. <GridBodyCellStatus>
  343. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  344. </GridBodyCellStatus>
  345. </GridRow>
  346. );
  347. }
  348. renderLoading() {
  349. return (
  350. <GridRow>
  351. <GridBodyCellStatus>
  352. <LoadingIndicator />
  353. </GridBodyCellStatus>
  354. </GridRow>
  355. );
  356. }
  357. renderEmptyData() {
  358. const {emptyMessage} = this.props;
  359. return (
  360. <GridRow>
  361. <GridBodyCellStatus>
  362. {emptyMessage ?? (
  363. <EmptyStateWarning>
  364. <p>{t('No results found for your query')}</p>
  365. </EmptyStateWarning>
  366. )}
  367. </GridBodyCellStatus>
  368. </GridRow>
  369. );
  370. }
  371. render() {
  372. const {title, headerButtons, scrollable, height} = this.props;
  373. const showHeader = title || headerButtons;
  374. return (
  375. <Fragment>
  376. <Profiler id="GridEditable" onRender={onRenderCallback}>
  377. {showHeader && (
  378. <Header>
  379. {title && <HeaderTitle>{title}</HeaderTitle>}
  380. {headerButtons && (
  381. <HeaderButtonContainer>{headerButtons()}</HeaderButtonContainer>
  382. )}
  383. </Header>
  384. )}
  385. <Body>
  386. <Grid
  387. data-test-id="grid-editable"
  388. scrollable={scrollable}
  389. height={height}
  390. ref={this.refGrid}
  391. >
  392. <GridHead>{this.renderGridHead()}</GridHead>
  393. <GridBody>{this.renderGridBody()}</GridBody>
  394. </Grid>
  395. </Body>
  396. </Profiler>
  397. </Fragment>
  398. );
  399. }
  400. }
  401. export default GridEditable;