@bigfishtv/cockpit
Version:
274 lines (243 loc) • 8.97 kB
JavaScript
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import * as SortTypes from '../../constants/SortTypes'
import * as Conditions from '../../constants/Conditions'
import { post } from '../../api/xhrUtils'
import { isObject } from '../../utils/typeUtils'
import { titleCase } from '../../utils/stringUtils'
import { showDeletePrompt } from '../../utils/promptUtils'
import { filterOutProtectedSchema, removeAssocationsFromSchema } from '../../utils/formUtils'
import { filterDataByQueryWithSchema, filterDataByFilterset, sortByObjectKey } from '../../utils/tableUtils'
import Tree from '../tree/Tree'
import Panel from '../container/panel/Panel'
import Button from '../button/Button'
import Bulkhead from '../page/Bulkhead'
import MainContent from '../container/MainContent'
import SearchInput from '../input/SearchInput'
import ReorderableCell from '../tree/ReorderableCell'
const DefaultPanelToolbar = ({ duplicable, handleDuplicate, selectedIds, data, handleDelete }) => (
<div className="gutter-left">
{duplicable && <Button text="Duplicate" onClick={handleDuplicate} disabled={selectedIds.length !== 1} />}
<Button text="Delete" onClick={handleDelete} style="error" disabled={!selectedIds.length} />
</div>
)
const DefaultBulkheadToolbar = ({ modelLabel, model, addUrl }) => (
<Button
text={'New ' + titleCase(modelLabel || model)}
onClick={() => (window.location.href = addUrl)}
style="primary"
size="large"
/>
)
// we define this because react-docgen fails when defaultProp directly references an imported component
const DefaultReorderableCell = props => <ReorderableCell {...props} />
export default class AutoReorderableIndex extends Component {
static propTypes = {
/** the lowercase plural model e.g. volunteer_applications */
model: PropTypes.string.isRequired,
/** the schema key for the order field, default is 'order' */
orderKey: PropTypes.string,
/** array of table schema passed in from backend */
schema: PropTypes.array,
/** array of entity assocations passed in from backend */
assocations: PropTypes.array,
/** function to handle when a row is double clicked */
onSelect: PropTypes.func,
/** default form value provided from backend, typically array of entity objects */
defaultValue: PropTypes.array,
/** query string to pre-entered */
defaultQuery: PropTypes.string,
/** filterset state */
defaultFilterset: PropTypes.object,
/** fields that start with an underscore (_) get excluded by default, this offers a way to whitelist some of those */
protectedFieldWhitelist: PropTypes.array,
/** panel toolbar component to replace default one, takes props: selectedIds, data, originalData, handleDelete, handleQueryChange, handleFilterChange, handleDataUpdate */
PanelToolbar: PropTypes.func,
/** panel toolbar component to replace default one, takes props: selectedIds, data, originalData, handleDelete, handleQueryChange, handleFilterChange, handleDataUpdate */
BulkheadToolbar: PropTypes.func,
}
static defaultProps = {
defaultValue: [],
schema: [],
associations: [],
orderKey: 'order',
model: 'model',
defaultQuery: '',
defaultFilterset: {},
protectedFieldWhitelist: [],
Cell: DefaultReorderableCell,
Sidebar: null,
PanelToolbar: DefaultPanelToolbar,
BulkheadToolbar: DefaultBulkheadToolbar,
}
constructor(props) {
super(props)
this.state = {
data: props.defaultValue,
selectedIds: [],
filterset: props.defaultFilterset,
query: props.defaultQuery,
loading: false,
}
}
getSubmitUrl(data) {
return data.id ? this.props.updateUrl + '/' + data.id + '.json' : this.props.addUrl
}
handleQueryChange = value => {
const query = typeof value == 'string' ? value : value.target.value
this.setState({ query, selectedIds: [] })
}
/**
* Overloaded function -- handles updates to filterset object, usually used by dropdown selectors
* @param {String / Object} property - is property or object of key values
* @param {String} value - is value of property, otherwise leave blank
*/
handleFilterChange = (property, value) => {
let { filterset } = this.state
if (isObject(property)) {
Object.keys(property).forEach(key => (filterset[key] = property[key]))
} else {
filterset[property] = value
}
this.setState({ filterset })
}
handleEdit = entity => {
if (this.props.onSelect) this.props.onSelect(entity)
else window.location.href = this.props.updateUrl + entity.id
}
handleDelete = () => {
const { data, selectedIds } = this.state
showDeletePrompt({
subject: this.props.modelLabel || null,
selectedIds,
data: data.filter(item => selectedIds.indexOf(item.id) >= 0),
queryUrl: this.props.deleteUrl,
callback: () => {
const deletedIds = selectedIds // deletedData.map(item => item.id)
const data = this.state.data.filter(item => deletedIds.indexOf(item.id) < 0)
this.setState({ data, selectedIds: [] })
},
})
}
handleReorder = (value, changes) => {
if (!changes.length) return
const { orderKey, reorderUrl } = this.props
const { selectedIds } = this.state
const oldData = [...this.state.data]
const entities = this.state.data
// begin new articles as current issue articles without any of the selected items
let newEntities = entities.filter(item => selectedIds.indexOf(item.id) < 0)
changes.forEach(change => {
const item = entities.reduce((prev, item) => (item.id === change.id ? item : prev), null)
if (item) newEntities.splice(change.index, 0, item)
})
// update order value
newEntities = newEntities.map((item, order) => ({ ...item, [orderKey]: order }))
// save the new orders
const postData = newEntities.reduce((data, entity) => ({ ...data, [entity.id]: entity[orderKey] }), {})
post({
url: reorderUrl,
data: postData,
quietSuccess: true,
failureMessage: 'Failed to reorder',
callback: () => {},
callbackError: () => this.setState({ data: oldData }),
})
this.setState({ data: newEntities })
}
handleDataUpdate = data => {
this.setState({ data, selectedIds: [] })
}
render() {
const {
schema,
associations,
model,
orderKey,
protectedFieldWhitelist,
Cell,
Sidebar,
PanelToolbar,
BulkheadToolbar,
} = this.props
const { data, query, selectedIds, filterset } = this.state
const _schema = filterOutProtectedSchema(removeAssocationsFromSchema(associations, schema), protectedFieldWhitelist)
const _data = filterDataByFilterset(
filterDataByQueryWithSchema(data, _schema, query),
filterset,
Conditions.AND
).sort(sortByObjectKey(orderKey, SortTypes.ASC, 'numeric'))
const panelProps = {
handleDelete: this.handleDelete,
handleQueryChange: this.handleQueryChange,
handleFilterChange: this.handleFilterChange,
handleDataUpdate: this.handleDataUpdate,
originalData: data,
data: _data,
selectedIds,
filterset,
query,
}
return (
<MainContent>
<Bulkhead title={titleCase(model)} Toolbar={() => <BulkheadToolbar {...this.props} />} />
<div className="finder">
{Sidebar && (
<div className="finder-menu">
<Sidebar {...panelProps} />
</div>
)}
<div className="finder-content" ref="finderContent">
<Panel
title={<SearchInput value={query} onChange={this.handleQueryChange} />}
PanelToolbar={PanelToolbar}
{...panelProps}>
<Tree
value={_data}
collapsedIds={[]}
TreeCell={Cell}
selectedIds={selectedIds}
treeItemTarget={treeItemTarget}
onChange={this.handleReorder}
onSelectItem={this.handleEdit}
onSelectionChange={selectedIds => this.setState({ selectedIds })}
onCombinationChange={({ selectedIds }) => this.setState({ selectedIds })}
/>
</Panel>
</div>
</div>
</MainContent>
)
}
}
/**
* We have to pass in our own treeItemTarget function to prevent the 'drag-into' functionality
*/
const EDGE_SIZE = 10
const treeItemTarget = {
drop(props, monitor, component) {
if (monitor.isOver({ shallow: true })) {
const draggedId = monitor.getItem().id
const targetId = props.id
const position = component.state.position
props.endDrag(draggedId, targetId, position)
}
},
hover(props, monitor, component) {
if (!props.reorderable) return
const isOverCurrent = monitor.isOver({ shallow: true })
if (isOverCurrent) {
const ownId = props.id
const draggedId = monitor.getItem().id
if (draggedId === ownId || component.props.selected) return
const boundingRect = ReactDOM.findDOMNode(component).getBoundingClientRect()
const clientOffset = monitor.getClientOffset()
const offsetY = clientOffset.y - boundingRect.top
if (clientOffset.y > boundingRect.top && clientOffset.y < boundingRect.bottom) {
if (offsetY < EDGE_SIZE) component.setState({ position: 'above' })
else component.setState({ position: 'below' })
}
}
},
}