123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- import {Component, createRef, Fragment, Profiler} from 'react';
- import {Location} from 'history';
- import EmptyStateWarning from 'sentry/components/emptyStateWarning';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {IconWarning} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {onRenderCallback} from 'sentry/utils/performanceForSentry';
- import {
- Body,
- Grid,
- GridBody,
- GridBodyCell,
- GridBodyCellStatus,
- GridHead,
- GridHeadCell,
- GridHeadCellStatic,
- GridResizer,
- GridRow,
- Header,
- HeaderButtonContainer,
- HeaderTitle,
- } from './styles';
- // Auto layout width.
- export const COL_WIDTH_UNDEFINED = -1;
- // Set to 90 as the edit/trash icons need this much space.
- export const COL_WIDTH_MINIMUM = 90;
- // For GridEditable, there are 2 generic types for the component, T and K
- //
- // - T is an element/object that represents the data to be displayed
- // - K is a key of T/
- // - columnKey should have the same set of values as K
- type ObjectKey = React.ReactText;
- export type GridColumn<K = ObjectKey> = {
- key: K;
- width?: number;
- };
- export type GridColumnHeader<K = ObjectKey> = GridColumn<K> & {
- name: string;
- };
- export type GridColumnOrder<K = ObjectKey> = GridColumnHeader<K>;
- export type GridColumnSortBy<K = ObjectKey> = GridColumn<K> & {
- order: 'desc' | 'asc';
- };
- /**
- * Store state at the start of "resize" action
- */
- export type ColResizeMetadata = {
- columnIndex: number; // Column being resized
- columnWidth: number; // Column width at start of resizing
- cursorX: number; // X-coordinate of cursor on window
- };
- type GridEditableProps<DataRow, ColumnKey> = {
- columnOrder: GridColumnOrder<ColumnKey>[];
- columnSortBy: GridColumnSortBy<ColumnKey>[];
- data: DataRow[];
- /**
- * GridEditable allows the parent component to determine how to display the
- * data within it. Note that this is optional.
- */
- grid: {
- onResizeColumn?: (
- columnIndex: number,
- nextColumn: GridColumnOrder<ColumnKey>
- ) => void;
- prependColumnWidths?: string[];
- renderBodyCell?: (
- column: GridColumnOrder<ColumnKey>,
- dataRow: DataRow,
- rowIndex: number,
- columnIndex: number
- ) => React.ReactNode;
- renderHeadCell?: (
- column: GridColumnOrder<ColumnKey>,
- columnIndex: number
- ) => React.ReactNode;
- renderPrependColumns?: (
- isHeader: boolean,
- dataRow?: DataRow,
- rowIndex?: number
- ) => React.ReactNode[];
- };
- location: Location;
- error?: React.ReactNode | null;
- /**
- * Inject a set of buttons into the top of the grid table.
- * The controlling component is responsible for handling any actions
- * in these buttons and updating props to the GridEditable instance.
- */
- headerButtons?: () => React.ReactNode;
- isLoading?: boolean;
- /**
- * GridEditable (mostly) do not maintain any internal state and relies on the
- * parent component to tell it how/what to render and will mutate the view
- * based on this 3 main props.
- *
- * - `columnOrder` determines the columns to show, from left to right
- * - `columnSortBy` is not used at the moment, however it might be better to
- * move sorting into Grid for performance
- */
- title?: string;
- };
- type GridEditableState = {
- numColumn: number;
- };
- class GridEditable<
- DataRow extends {[key: string]: any},
- ColumnKey extends ObjectKey
- > extends Component<GridEditableProps<DataRow, ColumnKey>, GridEditableState> {
- // Static methods do not allow the use of generics bounded to the parent class
- // For more info: https://github.com/microsoft/TypeScript/issues/14600
- static getDerivedStateFromProps(
- props: Readonly<GridEditableProps<Object, keyof Object>>,
- prevState: GridEditableState
- ): GridEditableState {
- return {
- ...prevState,
- numColumn: props.columnOrder.length,
- };
- }
- state: GridEditableState = {
- numColumn: 0,
- };
- componentDidMount() {
- window.addEventListener('resize', this.redrawGridColumn);
- this.setGridTemplateColumns(this.props.columnOrder);
- }
- componentDidUpdate() {
- // Redraw columns whenever new props are received
- this.setGridTemplateColumns(this.props.columnOrder);
- }
- componentWillUnmount() {
- this.clearWindowLifecycleEvents();
- window.removeEventListener('resize', this.redrawGridColumn);
- }
- private refGrid = createRef<HTMLTableElement>();
- private resizeMetadata?: ColResizeMetadata;
- private resizeWindowLifecycleEvents: {
- [eventName: string]: any[];
- } = {
- mousemove: [],
- mouseup: [],
- };
- clearWindowLifecycleEvents() {
- Object.keys(this.resizeWindowLifecycleEvents).forEach(e => {
- this.resizeWindowLifecycleEvents[e].forEach(c => window.removeEventListener(e, c));
- this.resizeWindowLifecycleEvents[e] = [];
- });
- }
- onResetColumnSize = (e: React.MouseEvent, i: number) => {
- e.stopPropagation();
- const nextColumnOrder = [...this.props.columnOrder];
- nextColumnOrder[i] = {
- ...nextColumnOrder[i],
- width: COL_WIDTH_UNDEFINED,
- };
- this.setGridTemplateColumns(nextColumnOrder);
- const onResizeColumn = this.props.grid.onResizeColumn;
- if (onResizeColumn) {
- onResizeColumn(i, {
- ...nextColumnOrder[i],
- width: COL_WIDTH_UNDEFINED,
- });
- }
- };
- onResizeMouseDown = (e: React.MouseEvent, i: number = -1) => {
- e.stopPropagation();
- // Block right-click and other funky stuff
- if (i === -1 || e.type === 'contextmenu') {
- return;
- }
- // <GridResizer> is nested 1 level down from <GridHeadCell>
- const cell = e.currentTarget!.parentElement;
- if (!cell) {
- return;
- }
- // HACK: Do not put into state to prevent re-rendering of component
- this.resizeMetadata = {
- columnIndex: i,
- columnWidth: cell.offsetWidth,
- cursorX: e.clientX,
- };
- window.addEventListener('mousemove', this.onResizeMouseMove);
- this.resizeWindowLifecycleEvents.mousemove.push(this.onResizeMouseMove);
- window.addEventListener('mouseup', this.onResizeMouseUp);
- this.resizeWindowLifecycleEvents.mouseup.push(this.onResizeMouseUp);
- };
- onResizeMouseUp = (e: MouseEvent) => {
- const metadata = this.resizeMetadata;
- const onResizeColumn = this.props.grid.onResizeColumn;
- if (metadata && onResizeColumn) {
- const {columnOrder} = this.props;
- const widthChange = e.clientX - metadata.cursorX;
- onResizeColumn(metadata.columnIndex, {
- ...columnOrder[metadata.columnIndex],
- width: metadata.columnWidth + widthChange,
- });
- }
- this.resizeMetadata = undefined;
- this.clearWindowLifecycleEvents();
- };
- onResizeMouseMove = (e: MouseEvent) => {
- const {resizeMetadata} = this;
- if (!resizeMetadata) {
- return;
- }
- window.requestAnimationFrame(() => this.resizeGridColumn(e, resizeMetadata));
- };
- resizeGridColumn(e: MouseEvent, metadata: ColResizeMetadata) {
- const grid = this.refGrid.current;
- if (!grid) {
- return;
- }
- const widthChange = e.clientX - metadata.cursorX;
- const nextColumnOrder = [...this.props.columnOrder];
- nextColumnOrder[metadata.columnIndex] = {
- ...nextColumnOrder[metadata.columnIndex],
- width: Math.max(metadata.columnWidth + widthChange, 0),
- };
- this.setGridTemplateColumns(nextColumnOrder);
- }
- /**
- * Recalculate the dimensions of Grid and Columns and redraws them
- */
- redrawGridColumn = () => {
- this.setGridTemplateColumns(this.props.columnOrder);
- };
- /**
- * Set the CSS for Grid Column
- */
- setGridTemplateColumns(columnOrder: GridColumnOrder[]) {
- const grid = this.refGrid.current;
- if (!grid) {
- return;
- }
- const prependColumns = this.props.grid.prependColumnWidths || [];
- const prepend = prependColumns.join(' ');
- const widths = columnOrder.map((item, index) => {
- if (item.width === COL_WIDTH_UNDEFINED) {
- return `minmax(${COL_WIDTH_MINIMUM}px, auto)`;
- }
- if (typeof item.width === 'number' && item.width > COL_WIDTH_MINIMUM) {
- if (index === columnOrder.length - 1) {
- return `minmax(${item.width}px, auto)`;
- }
- return `${item.width}px`;
- }
- if (index === columnOrder.length - 1) {
- return `minmax(${COL_WIDTH_MINIMUM}px, auto)`;
- }
- return `${COL_WIDTH_MINIMUM}px`;
- });
- // The last column has no resizer and should always be a flexible column
- // to prevent underflows.
- grid.style.gridTemplateColumns = `${prepend} ${widths.join(' ')}`;
- }
- renderGridHead() {
- const {error, isLoading, columnOrder, grid, data} = this.props;
- // Ensure that the last column cannot be removed
- const numColumn = columnOrder.length;
- const prependColumns = grid.renderPrependColumns
- ? grid.renderPrependColumns(true)
- : [];
- return (
- <GridRow>
- {prependColumns &&
- prependColumns.map((item, i) => (
- <GridHeadCellStatic key={`prepend-${i}`}>{item}</GridHeadCellStatic>
- ))}
- {
- /* Note that this.onResizeMouseDown assumes GridResizer is nested
- 1 levels under GridHeadCell */
- columnOrder.map((column, i) => (
- <GridHeadCell key={`${i}.${column.key}`} isFirst={i === 0}>
- {grid.renderHeadCell ? grid.renderHeadCell(column, i) : column.name}
- {i !== numColumn - 1 && (
- <GridResizer
- dataRows={!error && !isLoading && data ? data.length : 0}
- onMouseDown={e => this.onResizeMouseDown(e, i)}
- onDoubleClick={e => this.onResetColumnSize(e, i)}
- onContextMenu={this.onResizeMouseDown}
- />
- )}
- </GridHeadCell>
- ))
- }
- </GridRow>
- );
- }
- renderGridBody() {
- const {data, error, isLoading} = this.props;
- if (error) {
- return this.renderError();
- }
- if (isLoading) {
- return this.renderLoading();
- }
- if (!data || data.length === 0) {
- return this.renderEmptyData();
- }
- return data.map(this.renderGridBodyRow);
- }
- renderGridBodyRow = (dataRow: DataRow, row: number) => {
- const {columnOrder, grid} = this.props;
- const prependColumns = grid.renderPrependColumns
- ? grid.renderPrependColumns(false, dataRow, row)
- : [];
- return (
- <GridRow key={row}>
- {prependColumns &&
- prependColumns.map((item, i) => (
- <GridBodyCell key={`prepend-${i}`}>{item}</GridBodyCell>
- ))}
- {columnOrder.map((col, i) => (
- <GridBodyCell key={`${col.key}${i}`}>
- {grid.renderBodyCell
- ? grid.renderBodyCell(col, dataRow, row, i)
- : dataRow[col.key]}
- </GridBodyCell>
- ))}
- </GridRow>
- );
- };
- renderError() {
- return (
- <GridRow>
- <GridBodyCellStatus>
- <IconWarning color="gray300" size="lg" />
- </GridBodyCellStatus>
- </GridRow>
- );
- }
- renderLoading() {
- return (
- <GridRow>
- <GridBodyCellStatus>
- <LoadingIndicator />
- </GridBodyCellStatus>
- </GridRow>
- );
- }
- renderEmptyData() {
- return (
- <GridRow>
- <GridBodyCellStatus>
- <EmptyStateWarning>
- <p>{t('No results found for your query')}</p>
- </EmptyStateWarning>
- </GridBodyCellStatus>
- </GridRow>
- );
- }
- render() {
- const {title, headerButtons} = this.props;
- const showHeader = title || headerButtons;
- return (
- <Fragment>
- <Profiler id="GridEditable" onRender={onRenderCallback}>
- {showHeader && (
- <Header>
- {title && <HeaderTitle>{title}</HeaderTitle>}
- {headerButtons && (
- <HeaderButtonContainer>{headerButtons()}</HeaderButtonContainer>
- )}
- </Header>
- )}
- <Body>
- <Grid data-test-id="grid-editable" ref={this.refGrid}>
- <GridHead>{this.renderGridHead()}</GridHead>
- <GridBody>{this.renderGridBody()}</GridBody>
- </Grid>
- </Body>
- </Profiler>
- </Fragment>
- );
- }
- }
- export default GridEditable;
|