ember-frost-bunsen
Version:
Create UI's from JSON configurations.
866 lines (742 loc) • 26.8 kB
JavaScript
import {
actions,
generateView,
normalizeView,
reducer,
utils,
validateModel,
validateView,
viewV1ToV2
} from 'bunsen-core'
const {
CHANGE_VALUE,
change,
validate
} = actions
import Ember from 'ember'
import computed, {readOnly} from 'ember-computed-decorators'
import {task, timeout} from 'ember-concurrency'
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 {getSubModel} = utils
const {A, Component, Logger, RSVP, getOwner, isEmpty, isPresent, run, typeOf} = Ember
const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore)
import {
deemberify,
getMergedConfigRecursive,
isRegisteredEmberDataModel,
removeInternalValues,
validateRenderer
} from '../utils'
import layout from 'ember-frost-bunsen/templates/components/frost-bunsen-detail'
import {findCommonAncestor, traverseCellBreadthFirst} from 'ember-frost-bunsen/tree-utils'
const {defineProperty, keys} = Object
function getAlias (cell) {
if (cell.label) {
return cell.label
}
const words = Ember.String.dasherize(cell.model || 'Tab').replace('-', ' ')
return Ember.String.capitalize(words)
}
/**
* 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 typeOf(object) === 'instance'
}
/**
* 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) {
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 ===================================================
classNames: ['frost-bunsen-detail'],
layout,
// == State Properties =======================================================
propTypes: {
bunsenModel: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]).isRequired,
bunsenView: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]),
disableSchemaValidation: PropTypes.bool,
focusedInput: PropTypes.string,
hook: PropTypes.string,
hookInputDelimiter: PropTypes.string,
hookPrefix: PropTypes.string,
mergeDefaults: PropTypes.bool,
onInputFocusIn: PropTypes.func,
onInputFocusOut: PropTypes.func,
onFocusIn: PropTypes.func,
onFocusOut: PropTypes.func,
onChange: PropTypes.func,
onError: PropTypes.func,
onTabChange: PropTypes.func,
onValidation: PropTypes.func,
plugins: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]),
registeredComponents: PropTypes.array,
renderers: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.object
]),
selectedTabLabel: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.EmberObject,
PropTypes.null,
PropTypes.object
])
},
getDefaultProps () {
return {
isSchemaReady: false,
batchedChanges: {},
disableSchemaValidation: false,
disabled: false,
hook: 'bunsenDetail',
hookInputDelimiter: '-',
inputValidators: [],
mergeDefaults: false,
plugins: {},
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} view - the view to use (if given)
* @returns {BunsenView} the view to render
*/
renderView (model, view) {
return this.getRenderView(model, view)
},
/**
* Precompute all model dependencies
* @param {Object[]} cells - the view cells
* @param {Object} cellDefinitions - the view cell definitions
* @returns {Object} cellConfigs with precomputed model dependencies
*/
precomputedCells (bunsenModel, view) {
const {cellDefinitions, cells} = this.getRenderView(bunsenModel, view)
return cells.map((cell, index) => {
const cellConfig = _.cloneDeep(getMergedConfigRecursive(cell, cellDefinitions))
this.precomputeIds(cellConfig, undefined, index, bunsenModel)
this.precomputeDependencies(cellConfig)
let subModel = bunsenModel
if (cell.model) {
subModel = getSubModel(bunsenModel, cellConfig.model, cellConfig.dependsOn)
}
return {
bunsenModel: subModel,
cellConfig,
cellKey: cellConfig.__cellKey__
}
})
},
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(({bunsenModel, cellConfig, isRequired}, index) => {
const alias = getAlias(cellConfig)
// Since label is used for tab text don't render a label within tab as well
delete cellConfig.label
return {
alias,
bunsenModel,
cell: cellConfig,
id: `${index}-${Date.now()}`,
isRequired,
classNames: Ember.String.dasherize(alias)
}
})
return A(tabs)
},
tabSelection (cellTabs, selectedTabIndex) {
const selectedTab = cellTabs[selectedTabIndex]
return selectedTab ? selectedTab.id : cellTabs.get('0.id')
},
isInvalid (propValidationResult) {
return propValidationResult && !isEmpty(propValidationResult.errors)
},
// == Functions ==============================================================
/* eslint-disable complexity */
/**
* Apply updates from redux store
* @param {String} lastAction - last action that occurred in the redux store
* @param {Object} newProps - new component property values to apply
* @param {Object} validationResult - latest validation result
* @param {Object} value - latest form value
* @param {Boolean} hasSchemaChanges - whether a schema change has occurred
*/
applyStoreUpdate ({lastAction, newProps, validationResult, value, hasSchemaChanges}) {
if (hasSchemaChanges) {
const state = this.get('reduxStore').getState()
const view = this.getRenderView(state.model, state.view)
Object.assign(newProps, this.validateSchemas(state.baseModel, state.model, view))
newProps.isSchemaReady = true
}
// batch the property operations and prevent onChange loop for non-async changes
run.schedule('afterRender', () => {
// Update component properties with newer state from redux store.
this.setProperties(newProps)
// If the value changed inform consumer of the new form value. This occurs
// when defaults are applied within the redux store.
if ('renderValue' in newProps) {
this.informRenderersOfFormValueChange(value)
if (this.onChange) {
const valueWithoutInternalState = removeInternalValues(value)
this.onChange(valueWithoutInternalState)
}
}
// If the last action that occurred in the redux store was validation then
// inform consumer of latest validation results.
if (lastAction === 'VALIDATION_RESOLVED' && this.onValidation) {
this.onValidation(validationResult)
}
this.updateSelectedTab()
})
},
/* eslint-enable complexity */
/**
* Batches the changes to properties in a single run loop so changes to these props can be compared against
* the preceeding sync changes. If valueChangeSet is included in the props, special care is taken to merge
* the changeset rather than overriding it.
* @param {Object} properties - the properties to merge with the batched version
* @returns {Object} the newly updated batched properties
*/
batchChanges (properties) {
let batchedChanges = this.get('batchedChanges')
let changeSet = properties.valueChangeSet
delete properties.valueChangeSet
Object.assign(batchedChanges, properties)
if (changeSet) {
if (!batchedChanges.valueChangeSet) {
batchedChanges.valueChangeSet = new Map()
}
changeSet.forEach((value, key) => {
batchedChanges.valueChangeSet.set(key, value)
})
}
run.schedule('destroy', () => {
if (this.isDestroyed || this.isDestroying) return
this.set('batchedChanges', {})
})
return batchedChanges
},
/* eslint-disable complexity */
/**
* 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} view - the view to use (if given)
* @returns {BunsenView} the view to render
*/
getRenderView (model, view) {
if (isEmpty(view) || keys(view).length === 0) {
return generateView(model)
}
if (view.version === '1.0') {
view = v2View(view)
} else if (typeOf(view.get) === 'function' && view.get('version') === '1.0') {
view = v2View(deemberify(view))
}
return normalizeView(view)
},
/* eslint-enable complexity */
/**
* Inform renderers that registered to be notified of form value changes of
* new form value.
* @param {Object} value - new form value
*/
informRenderersOfFormValueChange (value) {
this.get('registeredComponents')
.forEach((component) => {
if (component.isDestroyed || component.isDestroying) return
component.formValueChanged(value)
})
},
/* 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.
* @param {Number} index - index relative to it's parent cell
* @param {Object} bunsenModel - the parent bunsen model
*/
precomputeIds (cellConfig, baseBunsenId = 'root', index, bunsenModel) {
let bunsenId = baseBunsenId
let subModel = bunsenModel || {}
// 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))
}
subModel = getSubModel(bunsenModel, cellConfig.model, cellConfig.dependsOn) || {}
}
let cellKey = index.toString()
if (!cellConfig.children) {
// only add bunsenIds to leaf nodes
addMetaProperty(cellConfig, '__bunsenId__', bunsenId)
cellKey = JSON.stringify(cellConfig) + JSON.stringify(subModel)
} else if (cellConfig.children) {
// recursive case for objects
cellConfig.children.forEach((child, index) => {
this.precomputeIds(child, bunsenId, index, subModel)
})
}
addMetaProperty(cellConfig, '__cellKey__', cellKey)
// recursive case for arrays
if (cellConfig.arrayOptions) {
const arrayModelTypes = ['itemCell', 'tupleCells']
arrayModelTypes.forEach((modelType) => {
const model = cellConfig.arrayOptions[modelType]
if (!model) {
return
}
if (Array.isArray(model)) {
const useIndexPlaceholder = modelType === 'itemCell'
const path = useIndexPlaceholder ? `${bunsenId}.[]` : bunsenId
const items = subModel.items
model.forEach((item, index) => {
this.precomputeIds(item, path, index, items ? items[index] : {})
})
} else {
this.precomputeIds(model, `${bunsenId}.[]`, index, subModel.items)
}
})
}
},
/**
* 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) {
const cellTypes = ['itemCell', 'tupleCells']
cellTypes.forEach((type) => {
const cell = config.arrayOptions[type]
if (!cell) return
if (Array.isArray(cell)) {
cell.forEach((item) => {
if (item.__dependency__) {
deps.push(item.__dependency__)
}
})
} else {
if (cell.__dependency__) {
deps.push(cell.__dependency__)
}
}
})
}
// determine the common dependency reference
addMetaProperty(config, '__dependency__', findCommonAncestor(deps))
})
},
/**
* 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 = {}
let batchedChanges = this.get('batchedChanges')
let hasSchemaChanges = false
if (!_.isEqual(errors, batchedChanges.errors || this.get('errors'))) {
newProps.errors = errors
}
if (!_.isEqual(batchedChanges.renderModel || this.get('renderModel'), state.model)) {
newProps.renderModel = state.model
newProps.baseModel = state.baseModel
hasSchemaChanges = true
}
if (!_.isEqual(batchedChanges.view || this.get('view'), state.view)) {
newProps.view = state.view
hasSchemaChanges = true
}
// 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 = valueChangeSet
}
batchedChanges = this.batchChanges(newProps)
if (batchedChanges.valueChangeSet) {
newProps.valueChangeSet = this.get('batchedChanges.valueChangeSet')
}
this.applyStoreUpdate({lastAction, newProps, validationResult, value, hasSchemaChanges})
},
/* eslint-enable complexity */
/**
* Setup redux store
*/
init () {
this._super(...arguments)
// Create redux store and initialze with starting state
const reduxStore = createStoreWithMiddleware(reducer, {
baseModel: {type: 'object'}
})
const {hook, hookPrefix} = this.getProperties(['hook', 'hookPrefix'])
const props = {
reduxStore
}
if (!isPresent(hookPrefix)) {
props.hookPrefix = hook
}
this.setProperties(props)
// Subscribe for redux store updates (so we can react to changes)
reduxStore.subscribe(this.storeUpdated.bind(this))
},
/**
* Validate schemas
* @param {BunsenModel} baseModel - bunsen model before conditions are evaluated
* @param {BunsenModel} model - bunsen model
* @param {BunsenView} view - bunsen view
* @returns {Object} validation results
*/
validateSchemas (baseModel, model, view) {
if (this.get('disableSchemaValidation')) {
return {}
}
const props = {
invalidSchemaType: 'model',
propValidationResult: validateModel(model, isRegisteredEmberDataModel)
}
// If model is valid then lets go ahead and validate the view
if (props.propValidationResult.errors.length === 0) {
const renderers = this.get('renderers') || {}
const validateRendererFn = validateRenderer.bind(null, getOwner(this))
Object.assign(props, {
invalidSchemaType: 'view',
propValidationResult: validateView(
view,
baseModel,
keys(renderers),
validateRendererFn,
isRegisteredEmberDataModel
)
})
}
// Make sure we aren't updating things that haven't actually changed
Object.keys(props).forEach((key) => {
if (_.isEqual(props[key], this.get('key'))) {
delete props[key]
}
})
return props
},
/* eslint-disable complexity */
/**
* Determines if the any of the schema attrs has changed
* @param {Object} oldSchema - the old schema
* @param {Object} newSchema - the new schema
* @returns {Object} the old and new schemas
*/
getSchema (oldSchema, newSchema) {
const oldSchemaPojo = isEmberObject(oldSchema) ? deemberify(oldSchema) : oldSchema
const newSchemaPojo = isEmberObject(newSchema) ? deemberify(newSchema) : newSchema
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)
}
this.triggerValidation()
},
triggerValidation () {
this.get('debouncedTriggerValidation').perform()
},
debouncedTriggerValidation: task(function * () {
yield timeout(100)
const model = this.get('renderModel')
const reduxStore = this.get('reduxStore')
const validators = this.getAllValidators()
const value = this.get('renderValue')
const mergeDefaults = this.get('mergeDefaults')
reduxStore.dispatch(
validate(null, value, model, validators, RSVP.all, true, mergeDefaults)
)
}).restartable(),
/**
* Update selected tab
*/
updateSelectedTab () {
let selectedTabLabel = this.get('selectedTabLabel')
if (selectedTabLabel === undefined) return
const selectedTab = this.get('cellTabs').findIndex((item) => item.alias === selectedTabLabel)
if (selectedTab === undefined) return
this.set('selectedTabIndex', selectedTab)
},
/**
* Keep value in sync with store and validate properties
*/
didReceiveAttrs () {
this._super(...arguments)
let dispatchValue
const oldAttrBunsenModel = this.get('_oldAttrBunsenModel')
const newAttrBunsenModel = this.get('bunsenModel') || this.get('options.bunsenModel')
const oldAttrBunsenView = this.get('_oldAttrBunsenView')
const newAttrBunsenView = this.get('bunsenView') || this.get('options.bunsenView')
const reduxStore = this.get('reduxStore')
const reduxStoreValue = this.get('renderValue')
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 {hasChanged: hasModelChanged, newSchema: newBunsenModel} = this.getSchema(oldAttrBunsenModel, newAttrBunsenModel) // eslint-disable-line max-len
const {hasChanged: hasViewChanged, newSchema: newView} = this.getSchema(oldAttrBunsenView, newAttrBunsenView)
const allValidators = this.getAllValidators()
const mergeDefaults = this.get('mergeDefaults')
// 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) {
if (reduxStoreValue !== null) {
const reduxStoreValueWithoutInternal = removeInternalValues(reduxStoreValue)
if (!_.isEqual(plainObjectValue, reduxStoreValueWithoutInternal)) {
dispatchValue = plainObjectValue
}
} else {
dispatchValue = plainObjectValue
}
}
const needsModelUpdate = hasModelChanged && newBunsenModel
const needsViewUpdate = hasViewChanged && newView
if (needsViewUpdate || needsModelUpdate) {
reduxStore.dispatch(change({
model: hasModelChanged ? newBunsenModel : undefined,
view: hasViewChanged ? newView : undefined
}))
}
// If we have a new value to assign the store then let's get to it
const needsValidation = dispatchValue || needsModelUpdate
if (needsValidation) {
reduxStore.dispatch(
validate(
null,
dispatchValue || value,
// TODO: remove this parameter when bunsen-core retrieves this from the store
hasModelChanged ? newBunsenModel : this.get('renderModel'),
allValidators,
RSVP.all,
undefined,
mergeDefaults
))
}
this.set('_oldAttrBunsenModel', newAttrBunsenModel)
this.set('_oldAttrBunsenView', newAttrBunsenView)
},
/* 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 {String} tabId - index of root cell corresponding to tab
*/
handleTabChange (tabId) {
const cellTabs = this.get('cellTabs')
const tabIndex = cellTabs.findIndex((tab) => tab.id === tabId)
this.set('selectedTabIndex', tabIndex)
if (this.onTabChange) {
const selectedTab = cellTabs[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)
}
},
/**
* Returns properties of the top-level form so inputs have easier access to them without requiring
* those properties be propagated
* @returns {Object} an object containing common form properties
*/
getRootProps () {
const {
focusedInput,
hookInputDelimiter,
lastFocusedInput,
plugins,
renderModel,
renderValue,
renderView,
validators
} = this.getProperties([
'focusedInput',
'hookInputDelimiter',
'lastFocusedInput',
'plugins',
'renderModel',
'renderValue',
'renderView',
'validators',
'value'
])
return {
focusedInput,
lastFocusedInput,
hookInputDelimiter,
model: renderModel,
plugins,
validators,
value: renderValue,
view: renderView
}
}
}
})