ember-frost-bunsen
Version:
Create UI's from JSON configurations.
611 lines (518 loc) • 18.5 kB
JavaScript
import {
actions,
generateView,
normalizeView,
reducer,
validateModel,
validateView,
viewV1ToV2
} from 'bunsen-core'
const {
CHANGE_VALUE,
changeModel,
validate
} = actions
import Ember from 'ember'
const {A, Component, Logger, RSVP, run, typeOf} = Ember
import computed, {readOnly} from 'ember-computed-decorators'
import getOwner from 'ember-getowner-polyfill'
import {HookMixin} from 'ember-hook'
import PropTypeMixin, {PropTypes} from 'ember-prop-types'
import SpreadMixin from 'ember-spread'
import _ from 'lodash'
import {applyMiddleware, createStore} from 'redux'
import thunkMiddleware from 'redux-thunk'
const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore)
import {
deemberify,
getMergedConfigRecursive,
isRegisteredEmberDataModel,
validateRenderer
} from '../utils'
import layout from 'ember-frost-bunsen/templates/components/frost-bunsen-detail'
import {findCommonAncestor, traverseCellBreadthFirst} from 'ember-frost-bunsen/tree-utils'
function getAlias (cell) {
if (cell.label) {
return cell.label
}
const words = Ember.String.dasherize(cell.model || 'Tab').replace('-', ' ')
return Ember.String.capitalize(words)
}
function getAttr (attrs, key) {
return (
_.get(attrs, `${key}.value`) ||
_.get(attrs, `options.value.${key}`)
)
}
/**
* Determine if an object is an Ember.Object or not
* @param {Object|Ember.Object} object - object to check
* @returns {Boolean} whether or not object is an Ember.Object
*/
function isEmberObject (object) {
return !_.isEmpty(object) && !_.isPlainObject(object)
}
/**
* Adds an unenumerable property to an object
* @param {Object} object - object to add property to
* @param {String} propName - property name
* @param {Object|Number|String} value - property value
*/
function addMetaProperty (object, propName, value) {
Object.defineProperty(object, propName, {
enumerable: false,
value
})
}
/*
* Convert v1 view to v2 view
* @param {Object} bunsenView - v1 bunsen view
* @returns {Object} v2 bunsen view
*/
function v2View (bunsenView) {
bunsenView = viewV1ToV2(bunsenView)
if (bunsenView.cells.length === 1) {
delete bunsenView.cells[0].label
}
return bunsenView
}
export default Component.extend(SpreadMixin, HookMixin, PropTypeMixin, {
// == Component Properties ===================================================
layout,
// == State Properties =======================================================
propTypes: {
bunsenModel: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]).isRequired,
bunsenView: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]),
hook: PropTypes.string,
onChange: PropTypes.func,
onError: PropTypes.func,
onTabChange: PropTypes.func,
onValidation: PropTypes.func,
registeredComponents: PropTypes.array,
renderers: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]),
selectedTabLabel: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.null,
PropTypes.object
])
},
getDefaultProps () {
return {
classNames: ['frost-bunsen-detail'],
disabled: false,
hook: 'bunsenDetail',
inputValidators: [],
renderers: {},
registeredComponents: [],
showAllErrors: false,
validators: [],
value: null
}
},
// == Computed Properties ====================================================
renderErrors (errors) {
return errors || {}
},
/**
* Get the view to render (generate one if consumer doesn't supply a view)
* @param {BunsenModel} model - the model schema to use to generate a view (if view is undefined)
* @param {BunsenView} bunsenView - the view to use (if given)
* @returns {BunsenView} the view to render
*/
renderView (model, bunsenView) {
if (_.isEmpty(bunsenView)) {
return generateView(model)
}
if (bunsenView.version === '1.0') {
bunsenView = v2View(bunsenView)
} else if (typeOf(bunsenView.get) === 'function' && bunsenView.get('view') === '1.0') {
bunsenView = v2View(deemberify(bunsenView))
} else {
bunsenView = _.cloneDeep(bunsenView)
}
return normalizeView(bunsenView)
},
/**
* Precompute all model dependencies
* @param {Object[]} cells - the view cells
* @param {Object} cellDefinitions - the view cell definitions
* @returns {Object} cellConfigs with precomputed model dependencies
*/
precomputedCellConfig (cells, cellDefinitions) {
let cellConfigs = cells.map(cell => getMergedConfigRecursive(cell, cellDefinitions))
cellConfigs.forEach((cellConfig) => {
this.precomputeIds(cellConfig)
this.precomputeDependencies(cellConfig)
})
return cellConfigs
},
cellTabs (cells) {
// If there is only one cell then we don't need to render tabs
if (cells.length === 1) {
return A([])
}
const tabs = cells.map((cell, index) => {
const alias = getAlias(cell)
// Since label is used for tab text don't render a label within tab as well
delete cell.label
return {
alias,
cell,
id: `${index}-${Date.now()}`,
classNames: Ember.String.dasherize(alias)
}
})
return A(tabs)
},
tabSelection (cellTabs, selectedTabIndex) {
const selectedTab = cellTabs.findBy('id', selectedTabIndex)
return selectedTab ? selectedTabIndex : cellTabs.get('0.id')
},
isInvalid (propValidationResult) {
return propValidationResult && !_.isEmpty(propValidationResult.errors)
},
// == Functions ==============================================================
/* eslint-disable complexity */
/**
* Precompute all model references from the view schema using the references `model` and `dependsOn`
* @param {Object} cellConfig - the cellConfig to precompute
* @param {String} [baseBunsenId] - the parent model path
* Note: Only object types can be precomputed. The array items are more dynamic and we'll denote
* them with [] in the bunsenIds. That means all array items under the path `model.path.[]` share
* the same bunsenId.
*/
precomputeIds (cellConfig, baseBunsenId = 'root') {
let bunsenId = baseBunsenId
// support relative model references
if (cellConfig.model) {
const nonIndexModel = cellConfig.model.replace(/\.\d+/g, '.[]')
bunsenId = `${baseBunsenId}.${nonIndexModel}`
if (cellConfig.dependsOn) {
const nonIndexDep = cellConfig.dependsOn.replace(/\.\d+/g, '.[]')
addMetaProperty(cellConfig, '__dependsOn__', bunsenId.replace(nonIndexModel, nonIndexDep))
}
}
if (!cellConfig.children || cellConfig.children.length === 0) {
// only add bunsenIds to leaf nodes
addMetaProperty(cellConfig, '__bunsenId__', bunsenId)
} else if (cellConfig.children) {
// recursive case for objects
cellConfig.children.forEach((child) => {
this.precomputeIds(child, bunsenId)
})
}
// recursive case for arrays
if (cellConfig.arrayOptions && cellConfig.arrayOptions.itemCell) {
this.precomputeIds(cellConfig.arrayOptions.itemCell, `${bunsenId}.[]`)
}
},
/**
* Precomputes the model dependencies or every cell in the view schema
* @param {Object} cellConfig - the view schema cell
*/
precomputeDependencies (cellConfig) {
// determine dependencies from the bottom up (agglomerative)
traverseCellBreadthFirst(cellConfig, (config) => {
let deps = []
// direct __bunsenId__ reference
if (config.__bunsenId__) {
deps.push(config.__bunsenId__)
}
// direct __dependsOn__ reference
if (config.__dependsOn__) {
deps.push(config.__dependsOn__)
}
// descendant object dependency reference
if (config.children) {
config.children.forEach((child) => {
if (child.__dependency__) {
deps.push(child.__dependency__)
}
})
}
// descendant array dependency references
if (config.arrayOptions && config.arrayOptions.itemCell) {
const itemCell = config.arrayOptions.itemCell
if (itemCell.__dependency__) {
deps.push(itemCell.__dependency__)
}
}
// determine the common dependency reference
addMetaProperty(config, '__dependency__', findCommonAncestor(deps))
})
},
/**
* Batches the changes in a change-set so multiple updates to a change-set
* don't override each other in a a single run loop
* @param {Map} changeSet - the changeSet to merge with the batched version
* @returns {Map} the newly updated batched change-sets
*/
batchChanges (changeSet) {
let batchedChangeSet = this.get('batchedChangeSet')
if (!batchedChangeSet) {
batchedChangeSet = new Map()
this.set('batchedChangeSet', batchedChangeSet)
}
changeSet.forEach((value, key) => {
batchedChangeSet.set(key, value)
})
run.schedule('afterRender', () => {
this.set('batchedChangeSet', null)
})
return batchedChangeSet
},
/**
* Keep UI in sync with updates to redux store
*/
storeUpdated () {
if (this.isDestroyed || this.isDestroying) {
return
}
const state = this.get('reduxStore').getState()
const {errors, lastAction, validationResult, value, valueChangeSet} = state
const hasValueChanges = valueChangeSet ? valueChangeSet.size > 0 : false
const newProps = {}
if (!_.isEqual(errors, this.get('errors'))) {
newProps.errors = errors
}
if (!_.isEqual(this.get('renderModel'), state.model)) {
newProps.renderModel = state.model
}
// we only want CHANGE_VALUE to update the renderValue since VALIDATION_RESULT should
// not be wired up to change renderValue
if (hasValueChanges && lastAction === CHANGE_VALUE) {
newProps.renderValue = value
newProps.valueChangeSet = this.batchChanges(valueChangeSet)
this.get('registeredComponents').forEach((component) => {
if (!component.isDestroyed && !component.isDestroying) {
component.formValueChanged(value)
}
})
}
this.setProperties(newProps)
if ('renderValue' in newProps && this.onChange) {
this.onChange(value)
}
if (('errors' in newProps || 'renderValue' in newProps) && this.onValidation) {
this.onValidation(validationResult)
}
},
/* eslint-enable complexity */
/**
* Setup redux store
*/
init () {
this._super(...arguments)
const value = this.get('value')
const plainObjectValue = isEmberObject(value) ? deemberify(value) : value
const reduxStore = createStoreWithMiddleware(reducer, {
baseModel: this.get('bunsenModel'),
value: plainObjectValue
})
this.set('reduxStore', reduxStore)
this.set('renderModel', reduxStore.getState().model)
reduxStore.subscribe(this.storeUpdated.bind(this))
},
/**
* Validate properties
* @param {Object} bunsenModel - a deemberified bunsenModel
*/
validateProps (bunsenModel) {
let invalidSchemaType = 'model'
const renderers = this.get('renderers') || {}
let result = validateModel(bunsenModel, isRegisteredEmberDataModel)
this.get('reduxStore').dispatch(changeModel(bunsenModel))
const view = this.get('renderView')
if (result.errors.length === 0) {
const validateRendererFn = validateRenderer.bind(null, getOwner(this))
invalidSchemaType = 'view'
result = validateView(view, bunsenModel, _.keys(renderers), validateRendererFn, isRegisteredEmberDataModel)
}
this.setProperties({
invalidSchemaType,
propValidationResult: result
})
},
/* eslint-disable complexity */
/**
* Determines if the any of the schema attrs has changed
* @param {String} schemaName - the name of the schema attribute
* @param {Object} oldAttrs - the old attributes
* @param {Object} newAttrs - the new attributes
* @returns {Object} the old and new schemas
*/
getSchema (schemaName, oldAttrs, newAttrs) {
const newSchema = getAttr(newAttrs, schemaName)
const newSchemaPojo = isEmberObject(newSchema) ? deemberify(newSchema) : newSchema
const oldSchema = getAttr(oldAttrs, schemaName)
const oldSchemaPojo = isEmberObject(oldSchema) ? deemberify(oldSchema) : oldSchema
return {
hasChanged: !_.isEqual(oldSchemaPojo, newSchemaPojo),
oldSchema: oldSchemaPojo,
newSchema: newSchemaPojo
}
},
/**
* Get form and input validators
* @returns {Function[]} list of validators
**/
getAllValidators () {
const formValidators = this.get('validators') || []
const inputValidators = this.get('inputValidators')
return formValidators.concat(inputValidators)
},
/**
* Registers a component validator
* @param {Ember.Component} component - the component that contains the validate method
*/
registerValidator (component) {
if ('validate' in component) {
const validator = component.validate.bind(component)
component.on('willDestroyElement', () => {
this.unregisterValidator(validator)
})
this.get('inputValidators').push(validator)
} else {
Logger.warn('registerValidator requires the component to implement validate()')
}
},
/**
* Unregisters a component validator
* @param {Function} validator - the validator function used when registering
*/
unregisterValidator (validator) {
const inputValidators = this.get('inputValidators')
const index = inputValidators.indexOf(validator)
if (index >= 0) {
inputValidators.splice(index, 1)
}
},
/**
* Keep value in sync with store and validate properties
*/
didReceiveAttrs ({newAttrs, oldAttrs}) {
this._super(...arguments)
let dispatchValue
const reduxStore = this.get('reduxStore')
const reduxStoreValue = reduxStore.getState().value
const value = this.get('value')
const plainObjectValue = isEmberObject(value) ? deemberify(value) : value
const hasUserProvidedValue = [null, undefined].indexOf(plainObjectValue) === -1
const isReduxStoreValueEmpty = [null, undefined].indexOf(reduxStoreValue) !== -1
const doesUserValueMatchStoreValue = _.isEqual(plainObjectValue, reduxStoreValue)
const {hasChanged: hasModelChanged, newSchema: newBunsenModel} = this.getSchema('bunsenModel', oldAttrs, newAttrs)
const {hasChanged: hasViewChanged} = this.getSchema('bunsenView', oldAttrs, newAttrs)
const allValidators = this.getAllValidators()
// If the store value is empty we need to make sure we we set it to an empty object so
// properties can be assigned to it via onChange events
if (isReduxStoreValueEmpty) {
dispatchValue = {}
}
// If the user/consumer has provided a value and it differs from the current store value
// then we need to update the store to be the user/consumer supplied value
if (hasUserProvidedValue && !doesUserValueMatchStoreValue) {
dispatchValue = plainObjectValue
}
// If we have a new value to assign the store then let's get to it
if (dispatchValue) {
reduxStore.dispatch(
validate(null, dispatchValue, this.get('renderModel'), allValidators, RSVP.all)
)
} else {
if (hasModelChanged) {
reduxStore.dispatch(
validate(null, value, newBunsenModel, allValidators, RSVP.all)
)
}
}
if (hasModelChanged || hasViewChanged) {
this.validateProps(newBunsenModel)
}
let selectedTabLabel = this.get('selectedTabLabel')
if (selectedTabLabel !== undefined) {
const selectedTab = this.get('cellTabs').findBy('alias', selectedTabLabel)
if (selectedTab !== undefined) {
this.set('selectedTabIndex', selectedTab.id)
}
}
},
/* eslint-enable complexity */
// == Actions ================================================================
actions: {
/**
* Nothing to handle, since value doesn't change for detail component
*/
handleChange () {},
/**
* When a renderer has an error (not a validation issue, but more a logic error)
* we report it back to the consumer. The main use-case here is API errors
* for select renderers and custom renderers
* @param {String} bunsenId - the busnen ID of the component that had an error
* @param {BunsenValidationError[]} errors - the errors that occurred they're not actually validation errors,
* but it's a handy format to use for errors
*/
handleError (bunsenId, errors) {
if (this.onError) {
this.onError(bunsenId, errors)
}
},
/**
* Change selected tab/root cell
* @param {Number} tabIndex - index of root cell corresponding to tab
*/
handleTabChange (tabIndex) {
this.set('selectedTabIndex', tabIndex)
if (this.onTabChange) {
const cellTabs = this.get('cellTabs')
const selectedTab = cellTabs.findBy('id', tabIndex)
this.onTabChange(selectedTab.alias)
}
},
/**
* Register a component for formValue changes
* @param {Ember.Component} component - the component being registered
*/
registerComponentForFormValueChanges (component) {
// Make sure when component is destroyed it unregisters for changes
component.on('willDestroyElement', () => {
component.unregisterForFormValueChanges(component)
})
// Make sure we inform component of formValue immediately
component.formValueChanged(this.get('renderValue') || {})
this.get('registeredComponents').push(component)
},
/**
* Unregister a component for formValue changes
* @param {Ember.Component} component - the component used to register
*/
unregisterComponentForFormValueChanges (component) {
const registeredComponents = this.get('registeredComponents')
const index = registeredComponents.indexOf(component)
if (index !== -1) {
registeredComponents.splice(index, 1)
}
}
}
})