@bigfishtv/cockpit
Version:
620 lines (572 loc) • 18.3 kB
JavaScript
import React, { Component, PureComponent } from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
import { get } from 'axios'
import { withFormValue } from '@bigfishtv/react-forms'
import { withRouter } from 'react-router-dom'
import newId from '../../utils/newId'
import { post } from '../../api/xhrUtils'
import { getImageUrl } from '../../utils/fileUtils'
import { getDataHash } from '../../utils/lifecycleUtils'
import { showDeletePrompt } from '../../utils/promptUtils'
import MainContent from '../container/MainContent'
import FilterForm from '../form/FilterForm'
import DataTable from '../table/DataTable'
import DefaultIndexFilter from '../table/AutoIndexFilter'
import DefaultIndexBulkhead from '../table/IndexBulkhead'
import SearchInput from '../input/SearchInput'
import Spinner from '../Spinner'
import { stringifyQuery } from '../../utils/urlUtils'
import Icon from '../Icon'
import DelayVisible from '../DelayVisible'
import Button from '../button/Button'
const assetCell = (value, rowProps) => {
if (!value || !value.kind) return <div />
switch (value.kind) {
case 'image':
case 'pdf': {
const url = getImageUrl(value, 'cockpit-tiny')
return <ImageCellRender key={url} url={url} rowProps={rowProps} />
}
default: {
const iconSize = 18
const paddingTop = (rowProps.style.height - 16) / 2 - iconSize / 2
const paddingLeft = (rowProps.style.width - 16) / 2 - iconSize / 2
return (
<div style={{ paddingLeft, paddingTop }}>
<Icon name="document" size={iconSize} />
</div>
)
}
}
}
const imageErrCache = {}
class ImageCellRender extends Component {
state = {
error: false,
loading: true,
url: '',
}
componentDidMount() {
this.loadImage()
}
componentWillUnmount() {
this.unmounting = true
if (this.state.loading) {
this.img.src = ''
}
}
loadImage() {
const url = this.props.url
if (!(url in imageErrCache)) {
this.img = new Image()
this.img.onerror = () => {
if (this.unmounting) return
imageErrCache[url] = true
this.setState({ loading: false, error: true })
}
this.img.onload = () => {
if (this.unmounting) return
imageErrCache[url] = false
this.setState({ loading: false })
}
this.img.src = url
this.setState({ loading: true, error: false })
} else {
this.setState({ loading: false, error: imageErrCache[url] })
}
}
render() {
if (this.state.loading) {
return null
}
if (this.state.error) {
const iconSize = 18
const paddingTop = (this.props.rowProps.style.height - 16) / 2 - iconSize / 2
const paddingLeft = (this.props.rowProps.style.width - 16) / 2 - iconSize / 2
return (
<div style={{ paddingLeft, paddingTop }}>
<Icon name="image-broken" size={iconSize} />
</div>
)
}
return (
<div
className="cover"
style={{ backgroundImage: `url('${this.props.url}')`, backgroundSize: 'cover', backgroundPosition: 'center' }}
/>
)
}
}
function defaultFieldModifier(field) {
if (field.association) {
if (field.association.className == 'Tank.Assets') {
return {
valueMapper: assetCell,
width: 60,
...field,
}
}
}
if (field.column) {
if (field.column.type == 'datetime') {
return { valueMapper: value => moment(value).format('D MMM Y, h:mma'), ...field }
} else if (field.column.type == 'boolean') {
return { valueMapper: value => (value ? '✓' : ''), ...field }
} else if (field.column.type == 'integer') {
return { width: field.column.length * 9, fixed: field.value === 'id', ...field }
}
}
return field
}
export function getFieldsFromSchema(schema = [], fieldModifier = val => val) {
const fields = []
for (let column of schema) {
let field = {
value: column.property,
text: column.title,
column,
}
if (column.order) field.order = column.order
field = defaultFieldModifier(field)
field = fieldModifier(field)
fields.push(field)
}
return fields
}
function associationNameToTitle(name) {
return name
.replace(/(\d+)/g, ' $1 ') // add spaces around numbers (eg. Vision6List -> Vision 6 List)
.replace(/([a-z])([A-Z])/g, '$1 $2') // add spaces between non-capital and capital letters (eg. ProductBrand -> Product Brand)
.trim() // just because we might have added extra spaces
}
export function getFieldsFromAssociations(associations = [], schema = [], fieldModifier = val => val) {
const fields = []
for (let association of associations) {
let field = {
value: association.property,
text: associationNameToTitle(association.name),
unsortable: true,
association,
}
switch (association.type) {
case 'belongsTo':
const order = schema.reduce((num, column, i) => (column.property === association.foreignKey ? i : num), false)
if (order !== false) field.order = order
break
case 'hasMany':
case 'belongsToMany':
field.valueMapper = val => val.length
break
}
field = defaultFieldModifier(field)
field = fieldModifier(field)
fields.push(field)
}
return fields
}
export function removeAssociationsFromSchema(schema = [], associations = []) {
const schemaPropertiesToExclude = associations.reduce((fields, association) => {
if (association.type == 'belongsTo') return [...fields, association.foreignKey]
return fields
}, [])
return schema.filter(column => schemaPropertiesToExclude.indexOf(column.property) < 0)
}
export function getFieldsFromSchemaAndAssociations(schema = [], associations = [], fieldModifier = val => val) {
const orderedSchema = schema.map((column, i) => ({ ...column, order: i }))
const cleanedSchema = removeAssociationsFromSchema(orderedSchema, associations)
let fields = [
...getFieldsFromSchema(cleanedSchema, fieldModifier),
...getFieldsFromAssociations(associations, orderedSchema, fieldModifier),
]
return fields
}
export function filterFields(fields = [], excludeFields) {
return fields.filter(field => {
if (typeof excludeFields == 'function') {
return excludeFields(field)
} else {
for (let pattern of excludeFields) {
if (!!field.value.match(pattern)) return false
}
return true
}
})
}
function warnAboutUnmatchedPatterns(excludeFields, fields) {
excludeFields.forEach(pattern => {
if (!fields.some(field => field.value.match(pattern))) {
console.warn(`[AutoPaginatedIndex] Unmatched pattern ${pattern} in "excludeFields".`)
}
})
}
const DefaultTableContainer = ({ Bulkhead, Filter, children, filterFields, componentProps, tableProps }) => (
<MainContent>
<Bulkhead {...componentProps} {...tableProps} />
<div className="flex-auto flex flex-column padding-medium">
<div className="flex-none">
<Filter filterFields={filterFields} {...componentProps} {...tableProps} />
</div>
{children}
</div>
</MainContent>
)
export default class AutoPaginatedIndex extends Component {
static propTypes = {
model: PropTypes.string.isRequired,
dataUrl: PropTypes.string,
submitUrl: PropTypes.string,
excludeFields: PropTypes.oneOfType([PropTypes.array, PropTypes.func]),
fields: PropTypes.array,
fieldModifier: PropTypes.func,
onFieldsCreated: PropTypes.func,
onRowSelected: PropTypes.func,
onSelectGlobalChange: PropTypes.func,
onSelectionChange: PropTypes.func,
onParamsChange: PropTypes.func,
schema: PropTypes.array,
associations: PropTypes.array,
schemaUrl: PropTypes.string,
schemaMapper: PropTypes.func,
associationsUrl: PropTypes.string,
associationsMapper: PropTypes.func,
schemaAssociationsUrl: PropTypes.string,
schemaAssociationsMapper: PropTypes.func,
resultsMapper: PropTypes.func,
defaultParams: PropTypes.object,
extraParams: PropTypes.object,
tableProps: PropTypes.shape({
title: PropTypes.string,
checkboxSelection: PropTypes.bool,
editable: PropTypes.bool,
duplicable: PropTypes.bool,
duplicateUrl: PropTypes.string,
duplicateTransformer: PropTypes.func,
exportable: PropTypes.bool,
exportUrl: PropTypes.string,
importable: PropTypes.bool,
importUrl: PropTypes.string,
deleteUrl: PropTypes.string,
}),
filterFields: PropTypes.arrayOf(
PropTypes.shape({
select: PropTypes.string.isRequired,
label: PropTypes.string,
Input: PropTypes.elementType,
inputProps: PropTypes.object,
})
),
}
static defaultProps = {
excludeFields: [],
schema: [],
associations: [],
fields: null,
fieldModifier: val => val,
schemaMapper: val => val,
associationsMapper: val => val,
schemaAssociationsMapper: val => val,
resultsMapper: data => data.data || data,
defaultParams: {},
extraParams: {},
tableProps: {
editable: true,
deletable: true,
exportable: false,
importable: false,
duplicable: false,
},
dataTableProps: {
checkboxSelection: true,
selectAllGlobal: false,
selectedIds: [],
selectedData: [],
},
TableContainer: DefaultTableContainer,
Bulkhead: DefaultIndexBulkhead,
Filter: DefaultIndexFilter,
filterFields: [{ select: 'q', label: 'Keyword', placeholder: 'Search...', Input: SearchInput }],
}
constructor(props) {
super(props)
this.state = {
schema: props.schema,
associations: props.associations,
loadingSchema: false,
loadingAssocations: false,
fields: (props.fields || []).map(defaultFieldModifier).map(this.props.fieldModifier),
errorMessage: '',
submitUrl: props.submitUrl || `/admin/${props.model}.json`,
schemaAssociationsUrl: props.schemaAssociationsUrl || `/admin/${props.model}/schema_associations.json`,
}
}
componentDidMount() {
this.loadSchemaAndAssociations()
}
loadSchemaAndAssociations = () => {
const {
schema,
schemaUrl,
schemaMapper,
associations,
associationsUrl,
associationsMapper,
schemaAssociationsMapper,
} = this.props
const { schemaAssociationsUrl, errorMessage } = this.state
if (errorMessage) {
this.setState({ errorMessage: '' })
}
if (!this.state.fields.length) {
console.log('no fields, gotta get some schema/associations')
if (!schema.length && !associations.length && schemaAssociationsUrl) {
console.log('getting schema & associations')
get(schemaAssociationsUrl)
.then(({ data }) => {
setTimeout(() => {
console.log('got schema & associations', data)
const { schema, associations } = schemaAssociationsMapper(data)
this.setState(
{
schema: schemaMapper(schema),
associations: associationsMapper(associations),
loadingSchema: false,
loadingAssocations: false,
},
() => this.generateFields()
)
})
})
.catch(() => this.setState({ errorMessage: 'Error loading schema & associations' }))
} else {
if (!schema.length && schemaUrl) {
this.setState({ loadingSchema: true })
get(schemaUrl)
.then(({ data }) => {
setTimeout(() => {
this.setState({ schema: schemaMapper(data), loadingSchema: false }, () => this.generateFields())
})
})
.catch(() => this.setState({ errorMessage: 'Error loading schema' }))
}
if (!associations.length && associationsUrl) {
this.setState({ loadingSchema: true })
get(associationsUrl)
.then(({ data }) => {
setTimeout(() => {
this.setState({ associations: associationsMapper(data), loadingAssocations: false }, () =>
this.generateFields()
)
})
})
.catch(() => this.setState({ errorMessage: 'Error loading associations' }))
}
}
}
}
generateFields() {
const { excludeFields, onFieldsCreated } = this.props
const { associations, schema, loadingAssocations, loadingSchema } = this.state
console.log('attempting to generate fields')
if (this.state.fields.length > 0 || loadingAssocations || loadingSchema) return
let fields = getFieldsFromSchemaAndAssociations(schema, associations, this.props.fieldModifier)
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(excludeFields)) {
warnAboutUnmatchedPatterns(excludeFields, fields)
}
}
fields = filterFields(fields, excludeFields)
console.log('generated fields', fields)
if (onFieldsCreated) onFieldsCreated({ fields, schema, associations })
this.setState({ fields })
}
render() {
const { tableProps, dataTableProps, model, TableContainer, Bulkhead, Filter, filterFields } = this.props
const { submitUrl, fields, loadingAssocations, loadingSchema, errorMessage } = this.state
if (loadingAssocations || loadingSchema || fields.length === 0)
return (
<MainContent>
<DelayVisible>
<div className="content align-items-center justify-content-center">
<div className="text-center">
{errorMessage ? <p>{errorMessage}</p> : <Spinner />}
{errorMessage && (
<Button style="primary" onClick={this.loadSchemaAndAssociations}>
Try again
</Button>
)}
</div>
</div>
</DelayVisible>
</MainContent>
)
return (
<FilterForm
submitUrl={submitUrl}
dataUrl={this.props.dataUrl}
resultsMapper={this.props.resultsMapper}
defaultValue={{ sort: 'id', direction: 'asc', limit: '25', ...this.props.defaultParams }}
extraParams={this.props.extraParams}
render={props => (
<PaginatedIndexMain
{...props}
fields={fields}
model={model}
extraParams={this.props.extraParams}
tableProps={tableProps}
dataTableProps={dataTableProps}
TableContainer={TableContainer}
Bulkhead={Bulkhead}
Filter={Filter}
filterFields={filterFields}
onRowSelected={this.props.onRowSelected}
onSelectGlobalChange={this.props.onSelectGlobalChange}
onSelectionChange={this.props.onSelectionChange}
onParamsChange={this.props.onParamsChange}
/>
)}
/>
)
}
}
class PaginatedIndexMain extends Component {
constructor(props) {
super(props)
this.fieldsHash = getDataHash(props.fields)
const params = props.formValue.value
const sort = localStorage[this.fieldsHash + '_sort']
const direction = localStorage[this.fieldsHash + '_direction']
if ((sort && sort != params.sort) || (direction && direction != params.direction)) {
props.formValue.update({ ...params, sort, direction })
}
this.state = {
selectedIds: [],
selectedData: [],
selectAllGlobal: false,
}
}
handleParamsChange = (changes = {}) => {
if ('sort' in changes) localStorage[this.fieldsHash + '_sort'] = changes.sort
if ('direction' in changes) localStorage[this.fieldsHash + '_direction'] = changes.direction
const params = { ...this.props.formValue.value, ...changes }
this.props.formValue.update(params)
if (this.props.onParamsChange) this.props.onParamsChange(params)
}
handleSelectionChange = (selectedIds, selectedData) => {
this.setState({ selectedIds, selectedData })
if (this.props.onSelectionChange) this.props.onSelectionChange(selectedIds, selectedData)
}
handleSelectGlobalChange = selectAllGlobal => {
this.setState({ selectAllGlobal })
if (this.props.onSelectGlobalChange) this.props.onSelectGlobalChange(selectAllGlobal)
}
handleNew = () => {
const { model } = this.props
this.props.history.push(`/${model}/add`)
}
handleRowSelected = row => {
const { tableProps } = this.props
if (this.props.onRowSelected) {
this.props.onRowSelected(row)
} else if (!('editable' in tableProps) || tableProps.editable) {
this.props.history.push(`/${this.props.model}/${row.id}`)
}
}
handleDelete = () => {
const { model } = this.props
const { deleteUrl } = this.props.tableProps
const { selectedIds, selectedData } = this.state
showDeletePrompt({
model,
queryUrl: deleteUrl,
selectedIds,
data: selectedData,
callback: deletedData => this.props.onSubmit(),
})
}
handleDuplicate = () => {
const { model } = this.props
const { duplicateUrl, duplicateTransformer } = this.props.tableProps
const item = this.state.selectedData[0]
const newItem = duplicateTransformer ? duplicateTransformer(item) : item
const url = duplicateUrl || `/admin/${model}/${item.id}.json?clone=1`
post({
url,
data: { ...newItem, id: newId() },
callback: result => this.props.onSubmit(),
})
}
handleImport = () => {
const { model } = this.props
const { importUrl } = this.props.tableProps
const url = importUrl ? importUrl : `/${model}/import`
this.props.history.push(url)
}
handleExport = () => {
const { model, formValue, extraParams } = this.props
const { exportUrl } = this.props.tableProps
const { selectedIds, selectAllGlobal } = this.state
const params =
selectAllGlobal || selectedIds.length === 0
? { ...extraParams, ...formValue.value }
: { ...extraParams, ...formValue.value, id: selectedIds }
const url = exportUrl ? exportUrl : `/admin/${model}/export.csv`
window.open(`${url}?${stringifyQuery(params)}`)
}
render() {
const { selectedData, selectedIds, selectAllGlobal } = this.state
const {
tableProps,
dataTableProps,
extraParams,
model,
data,
results,
fields,
formValue,
TableContainer,
Bulkhead,
Filter,
filterFields,
} = this.props
const checkboxSelection = 'checkboxSelection' in dataTableProps ? dataTableProps.checkboxSelection : true
const selection = {
selectedData,
selectedIds,
selectAllGlobal,
count: selectAllGlobal ? data.pagination.count : selectedData.length,
}
const componentProps = {
model,
selection,
data,
extraParams,
onCreate: this.handleNew,
onExport: this.handleExport,
onImport: this.handleImport,
onDelete: this.handleDelete,
onDuplicate: this.handleDuplicate,
onSubmit: this.props.onSubmit,
}
const children = (
<DataTable
{...dataTableProps}
checkboxSelection={checkboxSelection}
data={results}
pagination={data.pagination}
fields={fields}
params={formValue.value}
onParamsChange={this.handleParamsChange}
onRowSelected={this.handleRowSelected}
onSelectionChange={this.handleSelectionChange}
onSelectGlobalChange={this.handleSelectGlobalChange}
loading={this.props.submitStatus == 'busy'}
/>
)
return (
<TableContainer {...{ Filter, Bulkhead, children, componentProps, tableProps, dataTableProps, filterFields }} />
)
}
}