@bigfishtv/cockpit
Version:
382 lines (352 loc) • 11.7 kB
JavaScript
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import newId from '../../utils/newId'
import * as Conditions from '../../constants/Conditions'
import { showDeletePrompt } from '../../utils/promptUtils'
import { isObject } from '../../utils/typeUtils'
import { post } from '../../api/xhrUtils'
import { titleCase } from '../../utils/stringUtils'
import { filterOutProtectedSchema, removeAssocationsFromSchema } from '../../utils/formUtils'
import {
getFieldsFromSchemaAndAssociations,
filterDataByQueryWithFields,
filterDataByFilterset,
} from '../../utils/tableUtils'
import TableView from '../table/Table'
import Spinner from '../Spinner'
import MainContent from '../container/MainContent'
import Panel from '../container/panel/Panel'
import Bulkhead from '../page/Bulkhead'
import FilterInput from '../input/SearchInput'
import Hint from '../Hint'
import Button from '../button/Button'
const DefaultPanelToolbar = ({
movable,
duplicable,
handleMove,
handleDuplicate,
exportable,
handleExport,
selectedIds,
data,
handleDelete,
...props
}) => (
<div>
{duplicable && <Button text="Duplicate" onClick={handleDuplicate} disabled={selectedIds.length !== 1} />}
{movable && <Button text="Move" onClick={handleMove} disabled={!selectedIds.length} />}
<Button text="Delete" onClick={handleDelete} style="error" disabled={!selectedIds.length} />
{exportable && (
<Button text={selectedIds.length > 0 ? 'Export ' + selectedIds.length : 'Export'} onClick={handleExport} />
)}
</div>
)
const DefaultBulkheadToolbar = ({ modelLabel, model, addUrl }) => (
<Button
text={'New ' + titleCase(modelLabel || model)}
onClick={() => (window.location.href = addUrl)}
style="primary"
size="large"
/>
)
const DefaultPanelDrawer = ({ data, originalData, filterset, query, selectedIds }) => {
const filters = Object.keys(filterset).reduce(
(obj, key) => (filterset[key] !== null ? { ...obj, [key]: filterset[key] } : obj),
{}
)
const filterKeys = Object.keys(filters)
const numKeys = filterKeys.length
let queryStr = ''
if (!!query || numKeys > 0) queryStr += 'Filtered '
if (!!query) queryStr += `by "${query}" `
if (numKeys > 0) queryStr += 'where '
return (
<div className="panel-drawer">
<span style={{ float: 'left' }}>
Showing <strong>{data.length}</strong> of <strong>{originalData.length}</strong>
{selectedIds.length > 0 && (
<span>
–
<strong>{selectedIds.length === data.length ? 'All' : selectedIds.length}</strong> selected
</span>
)}
</span>
{(!!query || numKeys > 0) && (
<span style={{ float: 'right' }}>
{queryStr}
{numKeys > 0 &&
filterKeys.map((key, i) => {
let value = filters[key]
if (isObject(value)) value = ' is ' + JSON.encode(value)
else if (typeof value == 'function') value = ' matches a custom function'
else value = ' is ' + value.toString()
return (
<span key={key}>
{titleCase(key)}
{value}
{i < numKeys - 2 ? ', ' : i < numKeys - 1 ? ' and ' : '.'}
</span>
)
})}
</span>
)}
</div>
)
}
export class AutoTableIndexContainer extends Component {
static propTypes = {
/** the lowercase plural model e.g. volunteer_applications */
model: PropTypes.string,
/** array of table schema passed in from backend */
schema: PropTypes.array,
/** array of entity assocations passed in from backend */
assocations: PropTypes.array,
/** array of entity assocations passed in from backend */
hint: PropTypes.string,
/** function that receives assocations and schema column, returns a component */
componentResolver: PropTypes.func,
/** function that receives assocations and schema column, returns a field object to be merged with auto-generated field e.g. {width: 60} */
attributeModifier: PropTypes.func,
/** 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,
/** fields that start with an underscore (_) get excluded by default, this offers a way to whitelist some of those */
protectedFieldWhitelist: PropTypes.array,
/** panel title component to replace default search field */
PanelTitle: PropTypes.func,
/** panel toolbar component to replace default one, takes props: selectedIds, data, originalData, duplicable, handleDuplicate, handleDelete */
PanelToolbar: PropTypes.func,
/** Any extra props to pass through to PanelToolbar */
panelProps: PropTypes.object,
/** Function for rendering item titles, used in delete prompt */
renderListItem: PropTypes.func,
/** panel toolbar component to replace default one, takes props: selectedIds, data, originalData, duplicable, handleDuplicate, handleDelete */
BulkheadToolbar: PropTypes.func,
children: PropTypes.func,
duplicateTransformer: PropTypes.func,
}
static defaultProps = {
defaultValue: [],
schema: [],
associations: [],
model: 'model',
negativeHeight: 228,
movable: false,
duplicable: false,
exportable: false,
defaultQuery: '',
defaultFilterset: {},
panelProps: {},
renderListItem: null,
protectedFieldWhitelist: [],
PanelToolbar: DefaultPanelToolbar,
PanelDrawer: DefaultPanelDrawer,
BulkheadToolbar: DefaultBulkheadToolbar,
duplicateTransformer: item => item,
}
constructor(props) {
super(props)
this.state = {
data: props.defaultValue,
selectedIds: [],
filterset: props.defaultFilterset,
query: props.defaultQuery,
loading: false,
negativeHeight: props.hint ? props.negativeHeight + 49 : props.negativeHeight,
}
this.fields = getFieldsFromSchemaAndAssociations(
props.schema,
props.associations,
props.componentResolver,
props.attributeModifier
)
}
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: [] })
}
handleSelectionChange = selectedIds => {
this.setState({ selectedIds })
}
handleNegativeHeightChange = negativeHeight => {
this.setState({ negativeHeight })
}
/**
* 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 = (item, isNew = false) => {
if (this.props.onSelect) this.props.onSelect(item)
else window.location.href = this.props.updateUrl + item.id
}
handleDelete = () => {
const { data, selectedIds } = this.state
showDeletePrompt({
subject: this.props.modelLabel || null,
selectedIds,
renderListItem: this.props.renderListItem,
data: data.filter(item => selectedIds.indexOf(item.id) >= 0),
queryUrl: this.props.deleteUrl,
callback: deletedData => {
const deletedIds = selectedIds // deletedData.map(item => item.id)
const data = this.state.data.filter(item => deletedIds.indexOf(item.id) < 0)
this.setState({ data, selectedIds: [] })
},
})
}
handleDuplicate = () => {
const { model, duplicateUrl, duplicateTransformer } = this.props
const { data, selectedIds } = this.state
const selectedItem = data.filter(item => item.id === selectedIds[0])[0]
const url = duplicateUrl || `/admin/${model}/${selectedItem.id}.json?clone=1`
post({
url,
data: { ...duplicateTransformer(selectedItem), id: newId() },
callback: result => this.setState({ data: [...data, result] }),
})
}
handleExport = () => {
const { data, selectedIds } = this.state
const itemIds = selectedIds.length > 0 ? selectedIds : data.map(item => item.id)
let query = itemIds.map(id => 'ids[]=' + id).join('&')
window.open(this.props.exportUrl + '?' + query)
}
handleMove = () => {
if (this.props.onMove) {
const { data, selectedIds } = this.state
const selectedItems = data.filter(item => ~selectedIds.indexOf(item.id))
this.props.onMove(selectedItems, this.handleUpdateCallback)
}
}
handleUpdateCallback = mapperFunc => {
const { data } = this.state
const newData = mapperFunc(data)
this.setState({ data: newData, originalData: newData })
}
handleDataUpdate = data => {
this.setState({ data, originalData: data })
}
render() {
const { schema, associations, protectedFieldWhitelist, panelProps } = this.props
const { data, query, selectedIds, loading, filterset, negativeHeight } = this.state
const _schema = filterOutProtectedSchema(removeAssocationsFromSchema(associations, schema), protectedFieldWhitelist)
const _data = filterDataByFilterset(
filterDataByQueryWithFields(data, this.fields, query),
filterset,
Conditions.AND
)
const _panelProps = {
handleEdit: this.handleEdit,
handleDelete: this.handleDelete,
handleExport: this.handleExport,
handleMove: this.handleMove,
handleDuplicate: this.handleDuplicate,
handleQueryChange: this.handleQueryChange,
handleFilterChange: this.handleFilterChange,
handleDataUpdate: this.handleDataUpdate,
handleSelectionChange: this.handleSelectionChange,
handleNegativeHeightChange: this.handleNegativeHeightChange,
movable: this.props.movable,
duplicable: this.props.duplicable,
exportable: this.props.exportable,
originalData: data,
data: _data,
selectedIds,
filterset,
query,
...panelProps,
}
return this.props.children({
...this.props,
..._panelProps,
panelProps: _panelProps,
schema: _schema,
data: _data,
loading,
negativeHeight,
})
}
}
export class AutoTableIndexBase extends Component {
render() {
const {
PanelToolbar,
PanelDrawer,
PanelTitle,
panelProps,
handleQueryChange,
handleSelectionChange,
handleEdit,
handleNegativeHeightChange,
query,
hint,
negativeHeight,
loading,
schema,
associations,
data,
selectedIds,
...props
} = this.props
return (
<Panel
PanelToolbar={PanelToolbar}
PanelDrawer={PanelDrawer}
title={PanelTitle !== undefined ? PanelTitle : <FilterInput value={query} onChange={handleQueryChange} />}
{...panelProps}>
{hint && <Hint title={hint} onHide={() => handleNegativeHeightChange(negativeHeight - 49)} />}
{loading ? (
<div className="loader-center margin-top-xlarge">
<Spinner spinnerName="circle" />
</div>
) : (
<TableView
{...props}
schema={schema}
associations={associations}
data={data}
selectedIds={selectedIds}
onSelect={handleEdit}
onSelectionChange={handleSelectionChange}
negativeHeight={negativeHeight}
/>
)}
</Panel>
)
}
}
export default class AutoTableIndex extends Component {
render() {
return (
<AutoTableIndexContainer {...this.props}>
{props => {
const { model, panelProps, BulkheadToolbar } = props
return (
<MainContent>
<Bulkhead title={titleCase(model)} Toolbar={() => <BulkheadToolbar {...props} {...panelProps} />} />
<div className="finder">
<div className="finder-content" ref="finderContent">
<AutoTableIndexBase {...props} />
</div>
</div>
</MainContent>
)
}}
</AutoTableIndexContainer>
)
}
}