UNPKG

@bigfishtv/cockpit

Version:

418 lines (390 loc) 14 kB
import React, { Component } from 'react' import PropTypes from 'prop-types' import { AutoSizer, MultiGrid } from 'react-virtualized' import throttle from 'lodash/throttle' import _get from 'lodash/get' import cn from 'classnames' import { isFunction, isArray, isObject } from '../../utils/typeUtils' import { isCtrlKeyPressed, isShiftKeyPressed } from '../../utils/selectKeyUtils' import Button from '../button/Button' import Checkbox from '../input/Checkbox' import DefaultResultsFooter from './ResultsFooter' import { multisort, sortProperty } from '../../utils/sortUtils' import Icon from '../Icon' export class DefaultHeaderCell extends Component { constructor(props) { super(props) this.handleMouseMove = this.handleMouseMove.bind(this) this.handleMouseUp = this.handleMouseUp.bind(this) this.triggerResize = throttle(this.triggerResize, 1000 / 30) this.clientX = 0 this.originalWidth = props.style.width this.state = { resizing: false, startX: 0, } } componentDidMount() { document.addEventListener('mousemove', this.handleMouseMove) document.addEventListener('mouseup', this.handleMouseUp) } componentWillUnmount() { document.removeEventListener('mousemove', this.handleMouseMove) document.removeEventListener('mouseup', this.handleMouseUp) } handleMouseMove(event) { if (this.state.resizing) { this.clientX = event.clientX this.triggerResize() } } triggerResize() { this.props.onResize(this.originalWidth + (this.clientX - this.state.startX)) } handleMouseUp() { if (this.state.resizing) this.setState({ resizing: false }) } handleResizeStart(event) { event.preventDefault() this.originalWidth = this.props.style.width this.setState({ resizing: true, startX: event.clientX }) } render() { const { style, field, onClick, select, sort, direction, hover } = this.props return ( <div style={style} className={cn('data-header-cell', { hover })}> <a onClick={onClick}> {isFunction(field.text) ? field.text(this.props) : <strong>{field.text} </strong>} {!field.unsortable && select === sort && ( <span> <Icon name={direction === 'DESC' ? 'chevron-down' : 'chevron-up'} size={16} /> </span> )} </a> <div className="data-header-cell-resize" onMouseDown={e => this.handleResizeStart(e)} /> </div> ) } } export const DefaultContentCell = props => { const { style, field, row, hover, selected, onClick, onDoubleClick, onMouseOver, onMouseOut, rowIndex } = props let value = _get(row, field.value, null) if (value !== null && field.valueMapper) value = field.valueMapper(value, props) if (isArray(value)) value = `[${value.length}] Items` else if (isObject(value) && !value.hasOwnProperty('$$typeof')) value = '[Object]' return ( <div style={{ ...style }} className={cn('data-cell', 'truncate', { hover, selected }, rowIndex % 2 && 'data-cell-alt')} onClick={onClick} onDoubleClick={onDoubleClick} onMouseOver={onMouseOver} onMouseOut={onMouseOut} onMouseDown={e => e.preventDefault()}> {value} </div> ) } export function validateFields(fields) { fields = fields.map((field, i) => ({ fixed: false, order: i, ...field })) fields.sort(multisort([sortProperty('fixed', 'desc'), sortProperty('order')])) return fields } export default class DataTable extends Component { static propTypes = { data: PropTypes.array.isRequired, fields: PropTypes.array.isRequired, pagination: PropTypes.shape({ count: PropTypes.number.isRequired, current_page: PropTypes.number.isRequired, page_count: PropTypes.number.isRequired, has_next_page: PropTypes.bool, has_prev_page: PropTypes.bool, limit: PropTypes.number, }), onSelectionChange: PropTypes.func, onSelectGlobalChange: PropTypes.func, onParamsChange: PropTypes.func.isRequired, rowHighlight: PropTypes.bool, columnHighlight: PropTypes.bool, loading: PropTypes.bool, defaultRowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), selectAllGlobal: PropTypes.bool, selectedIds: PropTypes.arrayOf(PropTypes.number), selectedData: PropTypes.arrayOf(PropTypes.object), } static defaultProps = { fields: [], data: [], pagination: { count: 0, current_page: 0, page_count: 1, has_next_page: false, has_prev_page: false, limit: 50, }, checkboxSelection: false, defaultColumnWidth: 150, defaultRowHeight: 35, selectAllGlobal: false, selectedIds: [], selectedData: [], rowHighlight: true, columnHighlight: false, loading: false, HeaderCell: DefaultHeaderCell, ContentCell: DefaultContentCell, ResultsFooter: DefaultResultsFooter, } constructor(props) { super(props) this.cellRenderer = this.cellRenderer.bind(this) const fields = validateFields(props.fields) if (props.checkboxSelection) fields.unshift({ unsortable: true, fixed: true, width: 35, value: 'id', text: props => ( <Checkbox value={props.selectAll || props.selectAllGlobal} onChange={() => props.onSelectAll()} /> ), valueMapper: (value, props) => <Checkbox value={props.selected || false} onChange={() => props.onSelect()} />, }) this.state = { fields, columnWidths: fields.map(({ width }) => width || props.defaultColumnWidth), selectedIds: props.selectedIds, selectedData: props.selectedData, selectAllGlobal: props.selectAllGlobal, rowHoverIndex: null, columnHoverIndex: null, } } componentDidUpdate(prevProps) { if (!this.state.selectAllGlobal && this.state.selectedIds.length > 0 && this.props.params != prevProps.params) { this.handleDeselectAll() } } getFieldSelect(field) { return field.select || (this.props.model ? `${this.props.model}.${field.value}` : field.value) } handleHeaderColumnClick(field) { if (field.unsortable) return const { params } = this.props let { sort, direction } = params if (!sort) sort = this.getFieldSelect({ value: 'id' }) if (!direction) direction = 'ASC' const newSort = this.getFieldSelect(field) if (newSort == sort) direction = direction == 'ASC' ? 'DESC' : 'ASC' this.props.onParamsChange({ direction, sort: newSort }) // this.onParamChange({ ...params, sort: newSort, direction }) } handleSelectAll() { const { data } = this.props const { selectAllGlobal } = this.state if (selectAllGlobal) { this.setState({ selectedIds: [], selectedData: [], selectAllGlobal: false }) this.props.onSelectGlobalChange && this.props.onSelectGlobalChange(false) this.props.onSelectionChange && this.props.onSelectionChange([], []) } else if (this.state.selectedIds.length > 0 && data.length === this.state.selectedIds.length) { this.setState({ selectedIds: [], selectedData: [], selectAllGlobal: false }) this.props.onSelectionChange && this.props.onSelectionChange([], []) } else { const selectedIds = data.map(({ id }) => id) this.setState({ selectedIds, selectedData: [], selectAllGlobal: false }) this.props.onSelectionChange && this.props.onSelectionChange(selectedIds, data) } } handleSelectAllGlobal() { this.setState({ selectAllGlobal: true, selectedIds: [], selectedData: [] }) this.props.onSelectGlobalChange && this.props.onSelectGlobalChange(true) this.props.onSelectionChange && this.props.onSelectionChange([], []) } handleDeselectAll() { this.setState({ selectAllGlobal: false, selectedIds: [], selectedData: [] }) this.props.onSelectGlobalChange && this.props.onSelectGlobalChange(false) this.props.onSelectionChange && this.props.onSelectionChange([], []) } handleRowClick(index) { const { checkboxSelection, data } = this.props const row = data[index] let { selectedIds, selectAllGlobal } = this.state if (isShiftKeyPressed()) { if (selectAllGlobal) { selectAllGlobal = false selectedIds = data.map(({ id }) => id).filter((v, i) => i > index) } else if (selectedIds.length === 0) { selectedIndexes = [index] } else { const lowerIndex = Math.min(index, this.lastIndex) const upperIndex = Math.max(index, this.lastIndex) selectedIds = data.filter((v, i) => i >= lowerIndex && i <= upperIndex).map(({ id }) => id) } } else if (isCtrlKeyPressed() || checkboxSelection) { if (selectAllGlobal) { selectAllGlobal = false selectedIds = data.map(({ id }) => id) } selectedIds = ~selectedIds.indexOf(row.id) ? selectedIds.filter(id => id !== row.id) : [...selectedIds, row.id] } else { if (selectAllGlobal) selectAllGlobal = false selectedIds = selectedIds.length === 1 && selectedIds[0] == row.id ? [] : [row.id] } if (selectAllGlobal !== this.state.selectAllGlobal) { this.props.onSelectGlobalChange && this.props.onSelectGlobalChange(selectAllGlobal) } const selectedData = selectAllGlobal ? [] : data.filter(({ id }) => ~selectedIds.indexOf(id)) this.lastIndex = index this.setState({ selectedIds, selectAllGlobal, selectedData }) this.props.onSelectionChange && this.props.onSelectionChange(selectedIds, selectedData) } handleRowDoubleClick(row) { this.props.onRowSelected && this.props.onRowSelected(row) } handleColumnResize(columnIndex, width) { const columnWidths = [...this.state.columnWidths] columnWidths[columnIndex] = Math.max(width, 20) this.setState({ columnWidths }) if (this.grid) this.grid.recomputeGridSize() } cellRenderer({ columnIndex, rowIndex, key, isVisible, isScrolling, style }) { const field = this.state.fields[columnIndex] const { checkboxSelection, data, columnHighlight, rowHighlight } = this.props const { selectedIds, selectAllGlobal, columnHoverIndex, rowHoverIndex } = this.state const selectAll = selectedIds.length > 0 && selectedIds.length === data.length if (rowIndex === 0) { const HeaderCell = this.props.HeaderCell const cellProps = { style, isVisible, isScrolling, field, columnIndex, selectAll, selectAllGlobal, select: this.getFieldSelect(field), sort: this.props.params.sort || this.getFieldSelect({ value: 'id' }), direction: this.props.params.direction || 'ASC', onClick: () => this.handleHeaderColumnClick(field), onSelectAll: () => this.handleSelectAll(), onResize: width => this.handleColumnResize(columnIndex, width), } return <HeaderCell key={key} {...cellProps} /> } else { const row = data[rowIndex - 1] if (row || (checkboxSelection && columnIndex === 0)) { const ContentCell = this.props.ContentCell const cellProps = { style, isVisible, isScrolling, field, row, rowIndex, columnIndex, selected: selectAllGlobal || ~selectedIds.indexOf(row.id), hover: (columnHighlight && columnHoverIndex == columnIndex) || (rowHighlight && rowHoverIndex == rowIndex), onClick: checkboxSelection ? () => {} : () => this.handleRowClick(rowIndex - 1), onSelect: () => this.handleRowClick(rowIndex - 1), onDoubleClick: () => this.handleRowDoubleClick(row), onMouseOver: () => this.setState({ columnHoverIndex: columnIndex, rowHoverIndex: rowIndex }), } return <ContentCell key={key} {...cellProps} /> } else { return <div /> } } } getColumnWidth(index) { if (this.isLastColumn(index)) { const shortfall = this.calculateLastColumnShortfall() if (shortfall) { return this.state.columnWidths[index] + shortfall } } return this.state.columnWidths[index] || this.props.defaultColumnWidth } isLastColumn(index) { return index == this.state.fields.length - 1 } calculateLastColumnShortfall() { let shortfall = this._width for (let i = 0; i < this.state.fields.length; i++) { shortfall -= this.state.columnWidths[i] } if (shortfall > 0) { return shortfall } return 0 } render() { const { defaultRowHeight, data, pagination, ResultsFooter } = this.props const { selectedIds, fields, selectAllGlobal } = this.state const fixedColumnCount = fields.reduce((count, field) => count + (field.fixed ? 1 : 0), 0) return ( <div className="flex-1-0-auto flex flex-column data-table-container"> {selectedIds.length > 0 && selectedIds.length == data.length && ( <div className="flex-none data-message-row"> All {data.length} items on this page are selected.{' '} <Button text={`Select all ${pagination.count}`} size="xsmall margin-left-xsmall" style="tertiary" onClick={() => this.handleSelectAllGlobal()} /> </div> )} {selectAllGlobal && ( <div className="flex-none data-message-row"> All <strong>{pagination.count}</strong> items are selected. <Button text="Deselect all" size="xsmall margin-left-xsmall" style="tertiary" onClick={() => this.handleSelectAll()} /> </div> )} <div className="flex-1-0-auto" style={{ position: 'relative' }}> <AutoSizer> {({ width, height }) => { this._lastWidth = this._width this._width = width return ( <MultiGrid ref={ref => { this.grid = ref if (this.grid && this._lastWidth != width) { this.grid.recomputeGridSize() } }} fixedColumnCount={fixedColumnCount} enableFixedColumnScroll={true} classNameTopRightGrid="data-table-top-right" classNameBottomLeftGrid="data-table-bottom-left" fixedRowCount={1} width={width} height={height} cellRenderer={this.cellRenderer} columnCount={fields.length} columnWidth={({ index }) => this.getColumnWidth(index)} rowHeight={({ index }) => index === 0 ? 35 : isFunction(defaultRowHeight) ? defaultRowHeight({ index }) : defaultRowHeight } rowCount={data.length + 1} overscanRowCount={5} /> ) }} </AutoSizer> </div> {ResultsFooter && ( <div className="flex-none data-footer-row"> <ResultsFooter pagination={pagination} loading={this.props.loading} /> </div> )} </div> ) } }