Skip to content

Commit

Permalink
Merge 18896e0 into 3a68146
Browse files Browse the repository at this point in the history
  • Loading branch information
cjsheu committed Apr 1, 2020
2 parents 3a68146 + 18896e0 commit f263fd1
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 261 deletions.
3 changes: 1 addition & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ language: node_js
node_js:
- 8.6.0
addons:
chrome: stable
apt:
sources:
- ubuntu-toolchain-r-test
- google-chrome
packages:
- google-chrome-stable
- g++-4.8
firefox: latest
cache:
Expand Down
15 changes: 0 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,3 @@ Template instance
)
}}
```

* If you need completely dynamic properties (added to the hash after instantiation) this can be accomplished
by providing a source object and property to observe for property additions

```
{{component-foo
options=foo
spreadOptions=(hash
source=(hash
object=this
property='foo'
)
)
}}
```
266 changes: 71 additions & 195 deletions addon/mixins/spread.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
* Spreads the properties from a source object against the root level of the local object
*/

import {isArray, makeArray} from '@ember/array'

import {makeArray} from '@ember/array'
import {assert} from '@ember/debug'
import {defineProperty, get} from '@ember/object'
import {defineProperty} from '@ember/object'
import {readOnly} from '@ember/object/computed'
import Mixin from '@ember/object/mixin'
import {isNone, typeOf} from '@ember/utils'
Expand Down Expand Up @@ -47,64 +46,7 @@ export default Mixin.create({
// State
},

// == Computed Properties ===================================================

// == Functions =============================================================

/**
* Ember objects have a hook for dealing with previously undefined properties
* which allows these properties to be brought into the observer system on-the-fly.
*
* Sets this object as a listener for any unknown property additions.
*
* @param {object} sourceObject - the source object for the spread
* @param {string} sourceProperty - the source property for the spread
* @param {string} spreadProperty - the locally bound property for the spread
*/
_defineSourceListener (sourceObject, sourceProperty, spreadProperty) {
// Get or create the array of spread listeners on the source object and add this object
const spreadListeners = get(sourceObject, `${sourceProperty}._spreadListeners`)
if (isNone(spreadListeners)) {
get(sourceObject, sourceProperty).set('_spreadListeners', [
{
target: this,
targetProperty: spreadProperty
}
])
} else {
spreadListeners.push({
target: this,
targetProperty: spreadProperty
})
}

// Define the setUnknownProperty function on the source property so that we can
// monitor for the addition of new properties and spread them onto the local object
defineProperty(get(sourceObject, sourceProperty), 'setUnknownProperty', undefined,
function (key, value) {
// Set the property to the given value (the expected normal behavior)
this[key] = value

// For each listening target object (registered via spread options)
// spread the new property onto the target object
this._spreadListeners.forEach(listener => {
if (typeOf(value) === 'function') {
listener.target.set(key, value)
} else {
defineProperty(listener.target, key,
readOnly(`${listener.targetProperty}.${key}`)
)
}
})

// Notify all downstream listeners that the property has changed.
// This triggers the first observation of the property for the newly
// defined computed property on the target object(s)
sourceObject.get(sourceProperty).notifyPropertyChange(key)
}
)
},

/**
* Create local properties for each property in the spread hash.
* Functions are set directly against the local object. Properties listed in
Expand Down Expand Up @@ -158,7 +100,7 @@ export default Mixin.create({
} else {
this.set(key, makeArray(baseValue).concat(value))
}

this.notifyPropertyChange(`${key}`)
return
}

Expand All @@ -174,34 +116,14 @@ export default Mixin.create({
this.set(key, assign({}, value))
}
}

this.notifyPropertyChange(`${key}`)
return
}

defineProperty(this, key, readOnly(`${spreadProperty}.${key}`))
this.notifyPropertyChange(`${key}`)
})
},

/**
* Get the source object and property for the spread hash
*
* @returns {object} - the source object and property for the spread hash
*/
_getSourceContext () {
return {
sourceObject: this.get('spreadOptions.source.object'),
sourceProperty: this.get('spreadOptions.source.property')
}
},

/**
* @param {object} listener - a listener object for setUnknownProperty
* @returns {boolean} - true if the given listener came from this object
*/
_isLocalListener (listener) {
return listener.target === this
},

/**
* Reset local properties to undefined for each property in the spread hash to break the observer.
* Properties listed in the component's `concatenatedProperties` or `mergedProperties`
Expand All @@ -212,18 +134,18 @@ export default Mixin.create({
* Note: We're currently using the private Ember defineProperty function
* which is required to establish observer chains (accept computed properties)
*
* @param {object} spreadHash - the hash to remove
*/
_resetSpreadProperties (spreadHash) {
_resetSpreadProperties () {
const staticProperties = ['tagName', 'elementId']
const concatenatedProperties = this.concatenatedProperties || makeArray()
const mergedProperties = this.mergedProperties || makeArray()
const spreadProperties = this.get('_spreadProperties')

if (isNone(spreadHash)) {
if (isNone(spreadProperties)) {
return
}

keys(spreadHash).forEach(key => {
spreadProperties.forEach(key => {
// We don't reset tagName, elementId, concatenatedProperties and
// mergedProperties as we won't support change them on the fly.
if (staticProperties.includes(key) ||
Expand All @@ -237,135 +159,89 @@ export default Mixin.create({
// going to remove all registered computed properties.
defineProperty(this, key, undefined, undefined)
})
},

/**
* This function works the same as _defineSpreadProperties with leaving `staticProperties`,
* `concatenatedProperties` and `mergedProperties` untouched.
*
* Note: We're currently using the private Ember defineProperty function
* which is required to establish observer chains (accept computed properties)
*
* @param {string} spreadProperty - the name of the local property containing the hash
* @param {object} spreadHash - the hash object to spread
*/
_redefineSpreadProperties (spreadProperty, spreadHash) {
const staticProperties = ['tagName', 'elementId']
const concatenatedProperties = this.concatenatedProperties || makeArray()
const mergedProperties = this.mergedProperties || makeArray()

if (isNone(spreadHash)) {
return
}

keys(spreadHash).forEach(key => {
if (EXCLUDED_PROPERTIES.includes(key)) {
return
}

// We won't support changing tagName, elementId, concatenatedProperties and
// mergedProperties on the fly.
if (staticProperties.includes(key) ||
concatenatedProperties.includes(key) ||
mergedProperties.includes(key)
) {
return
}

defineProperty(this, key, readOnly(`${spreadProperty}.${key}`))
})
this.set('_spreadProperties', new Set())
},

// == Ember Lifecycle Hooks =================================================

init () {
this._super(...arguments)

// Get the spreadable hash
const spreadProperty = this.get('spreadOptions.property') || SPREAD_PROPERTY
const spreadableHash = this.get(spreadProperty)

this._watchSpreadPropertiesUpdate(spreadProperty)

if (isNone(spreadableHash)) {
return
const {propertyPath, spreadSource} = this._getSpreadSource()
if (spreadSource) {
const spreadProperties = new Set(Object.keys(spreadSource))
this.set('_spreadProperties', spreadProperties)
this._addSetUnsupportedProperty(propertyPath)
this._defineSpreadProperties(propertyPath, spreadSource)
}

// Spread the properties in the hash onto the local object
this._defineSpreadProperties(spreadProperty, spreadableHash)

// Cache the spreadable hash so we can look it up later for cleanup.
this.set('_spreadableHash', spreadableHash)
this.addObserver(propertyPath, this, this._sourceChangeObserverHandler)
},

// The above spread only works on properties that were defined on the
// hash when it was passed to this context. However, if we add a listener
// to the original object hash in the original context then we can determine
// when a new property is added and define a property in this context on-the-fly
const {sourceObject, sourceProperty} = this._getSourceContext()
if (isNone(sourceObject) || isNone(sourceProperty)) {
return
/**
* return an object that has the source object that needs to be spread onto current
* component and the path to the source relative to the current object.
* @returns {{spreadSource: object, propertyPath: string}} a hash with spread source reference and its path.
* @private
*/
_getSpreadSource () {
// Get the source of spreadable hash, can be either
// this.options (default) OR
// this.${spreadOptions.property} (custom)
// spreadOptions.source.object.${spreadOptions.source.property} (with dynamic properties)
let propertyPath = this.get('spreadOptions.property') || SPREAD_PROPERTY
if (this.get('spreadOptions.source.object')) {
const pathSuffix = this.get('spreadOptions.source.property') || SPREAD_PROPERTY
propertyPath = `spreadOptions.source.object.${pathSuffix}`
}
return {
propertyPath,
spreadSource: this.get(propertyPath)
}

// Define a listener for any new properties on the source property
this._defineSourceListener(sourceObject, sourceProperty, spreadProperty)
},

/**
* Establish an observer that will notify the spread system whenever the source spreadable property
* is being replaced entirely. On the event trigger, the callback will go through all existing spread
* property and stops any observers attached to them. Then new read only computed properties and
* unknownProperty listeners will be created based on the new/replaced source property.
*
* @param {string} spreadProperty - the name of the local property containing the hash
* Adding {@code setUnknownProperty} to the spread source object if it does not have it
* in order to be able to detect addition of properties in the spread source object.
* @param {string} spreadPropertyPath - path to the spread source object (relative to current component)
* @private
*/
_watchSpreadPropertiesUpdate (spreadProperty) {
const {sourceObject, sourceProperty} = this._getSourceContext()

this.addObserver(`spreadOptions.source.object.${sourceProperty}`, function () {
const spreadableHash = this.get(`spreadOptions.source.object.${sourceProperty}`)

// This block is to prevent the observer from firing twice on single property change.
if (this._spreadableHash === spreadableHash) {
return
}

this._resetSpreadProperties(this._spreadableHash)

// Cache the spreadable hash so we can look it up later for cleanup.
this.set('_spreadableHash', spreadableHash)

if (isNone(spreadableHash)) {
return
}

// Redefine the spread properties based on the new spreadableHash.
this._redefineSpreadProperties(spreadProperty, spreadableHash)
_addSetUnsupportedProperty (spreadPropertyPath) {
const spreadSource = this.get(`${spreadPropertyPath}`)
spreadSource.setUnknownProperty = (key, value) => {
spreadSource[key] = value
this._defineSpreadProperties(spreadPropertyPath, {
[`${key}`]: value
})
this.get('_spreadProperties').add(key)
}
},

// A not about removing existing UnknownProperty listeners.
// The original listeners are saved on the original source property object, as the object
// is completely gone now, we shouldn't worry about removing these listeners.
this._defineSourceListener(sourceObject, sourceProperty, spreadProperty)
})
/**
* observer to detect changes in the spread source reference so that we can update
* spread properties
* @private
*/
_sourceChangeObserverHandler () {
const {propertyPath, spreadSource} = this._getSpreadSource()
if (spreadSource === undefined) {
this._resetSpreadProperties()
} else if (spreadSource.setUnknownProperty === undefined) {
this._resetSpreadProperties()
this._addSetUnsupportedProperty(propertyPath)
}
if (spreadSource) {
this._defineSpreadProperties(propertyPath, spreadSource)
}
},

willDestroy () {
this._super(...arguments)

const {sourceObject, sourceProperty} = this._getSourceContext()
if (isNone(sourceObject) || isNone(sourceProperty)) {
return
}

const spreadListeners = get(sourceObject, `${sourceProperty}._spreadListeners`)

// Remove this listener from the source object property
if (isArray(spreadListeners)) {
spreadListeners.splice(spreadListeners.findIndex(this._isLocalListener), 1)
}
this._resetSpreadProperties()
this.set('_spreadProperties', undefined)
const {propertyPath} = this._getSpreadSource()
this.removeObserver(propertyPath, this, this._sourceChangeObserverHandler)
}

// == DOM Events ============================================================

// == Actions ===============================================================

})

0 comments on commit f263fd1

Please sign in to comment.