index.tsx 12 KB

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