@bigfishtv/cockpit
Version:
418 lines (390 loc) • 14 kB
JavaScript
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>
)
}
}