index.tsx 12 KB

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