UNPKG

@bigfishtv/cockpit

Version:

620 lines (572 loc) 18.3 kB
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} /> )} /> ) } } @withRouter @withFormValue 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 }} /> ) } }