diff --git a/ember-exclaim/src/-private/bind-computeds.js b/ember-exclaim/src/-private/bind-computeds.js new file mode 100644 index 0000000..802f4e4 --- /dev/null +++ b/ember-exclaim/src/-private/bind-computeds.js @@ -0,0 +1,109 @@ +/* eslint-disable ember/no-computed-properties-in-native-classes */ +import { get, set, defineProperty, computed } from '@ember/object'; +import { deprecate } from '@ember/debug'; +import { alias } from '@ember/object/computed'; +import { HelperSpec, Binding } from './ui-spec.js'; +import { recordCanonicalPath } from './paths.js'; + +/** + * Given a piece of a UI spec `data` and an environment `env`, + * locates all `Binding` and `HelperSpec` values and installs + * Ember computed properties with appropriate dependencies + * in their place. + * + * Note that this does not recurse through `ComponentSpec` values, + * as the embedded config within those should not be bound until + * the component spec is yielded and we know what environment to + * bind it to. + */ +export function bindComputeds(data, env) { + if (Array.isArray(data)) { + let result = Array(data.length); + for (let i = 0; i < data.length; i++) { + bindKey(result, i, data[i], env); + } + return result; + } else if ( + typeof data === 'object' && + data && + Object.getPrototypeOf(data) === Object.prototype + ) { + let result = new ConfigObject(); + for (let key of Object.keys(data)) { + bindKey(result, key, data[key], env); + } + return result; + } else { + return data; + } +} + +function bindKey(host, key, value, env) { + if (value instanceof Binding) { + recordCanonicalPath(host, key, env, value.path.join('.')); + defineProperty( + host, + key, + alias(`${getEnvKey(host, env)}.${value.path.join('.')}`) + ); + } else if (value instanceof HelperSpec) { + const envKey = getEnvKey(host, env); + const dependentKeys = value.bindings.map( + (binding) => `${envKey}.${binding.path.join('.')}` + ); + defineProperty( + host, + key, + computed(...dependentKeys, { get: () => value.invoke(env) }) + ); + } else { + host[key] = bindComputeds(value, env); + } +} + +const envKeys = new WeakMap(); +function getEnvKey(object, environment) { + const key = envKeys.get(object); + if (key) { + return key; + } + + const envKey = `-environment-${Math.random().toString().slice(2)}`; + Object.defineProperty(object, envKey, { + value: environment, + enumerable: false, + writable: false, + }); + envKeys.set(object, envKey); + return envKey; +} + +class ConfigObject { + get(key) { + deprecate( + 'Calling `.get()` on UI config objects is deprecated. Use normal direct property access.', + true, + { + id: 'ember-exclaim.get-set', + for: 'ember-exclaim', + since: { available: '2.0.0', enabled: '2.0.0' }, + until: '3.0.0', + } + ); + return get(this, key); + } + + set(key, value) { + deprecate( + 'Directly calling `.set()` on UI config objects is deprecated. Use the importable `set` or set via a parent object.', + true, + { + id: 'ember-exclaim.get-set', + for: 'ember-exclaim', + since: { available: '2.0.0', enabled: '2.0.0' }, + until: '3.0.0', + } + ); + return set(this, key, value); + } +} diff --git a/ember-exclaim/src/-private/binding.js b/ember-exclaim/src/-private/binding.js deleted file mode 100644 index cce38c8..0000000 --- a/ember-exclaim/src/-private/binding.js +++ /dev/null @@ -1,5 +0,0 @@ -export default class Binding { - constructor(path) { - this.path = path.split('.'); - } -} diff --git a/ember-exclaim/src/-private/build-spec-processor.js b/ember-exclaim/src/-private/build-spec-processor.js index 1bf5795..b27a61d 100644 --- a/ember-exclaim/src/-private/build-spec-processor.js +++ b/ember-exclaim/src/-private/build-spec-processor.js @@ -1,7 +1,5 @@ -import Binding from './binding'; -import ComponentSpec from './component-spec'; -import HelperSpec from './helper-spec'; import { transform, rule, simple, subtree, rest } from 'botanist'; +import { ComponentSpec, HelperSpec, Binding } from './ui-spec.js'; const hasOwnProperty = Function.prototype.call.bind( Object.prototype.hasOwnProperty diff --git a/ember-exclaim/src/-private/component-spec.js b/ember-exclaim/src/-private/component-spec.js deleted file mode 100644 index 13d42e6..0000000 --- a/ember-exclaim/src/-private/component-spec.js +++ /dev/null @@ -1,13 +0,0 @@ -import { wrap } from './environment'; - -export default class ComponentSpec { - constructor(path, config, meta) { - this.path = path; - this.config = config; - this.meta = meta; - } - - resolveConfig(env) { - return wrap(this.config, env); - } -} diff --git a/ember-exclaim/src/-private/environment.js b/ember-exclaim/src/-private/environment.js index 2258328..8a5eb4c 100644 --- a/ember-exclaim/src/-private/environment.js +++ b/ember-exclaim/src/-private/environment.js @@ -1,11 +1,8 @@ +/* eslint-disable ember/no-computed-properties-in-native-classes */ import { makeArray } from '@ember/array'; -import { set, get } from '@ember/object'; -import { isHTMLSafe } from '@ember/template'; -import createEnvComputed from './environment/create-env-computed'; -import EnvironmentData from './environment/data'; -import EnvironmentArray from './environment/array'; -import Binding from './binding'; -import { extractKey } from './environment/utils'; +import { set, get, computed, defineProperty } from '@ember/object'; +import { resolveCanonicalPath } from './paths'; +import { bindComputeds } from './bind-computeds'; /* * Wraps an object that may contain exclaim Bindings, automatically resolving @@ -29,6 +26,10 @@ export default class Environment { return set(this, key, value); } + bind(data) { + return bindComputeds(data, this); + } + on(type, listener) { let listeners = this.__listeners__[type] || (this.__listeners__[type] = []); listeners.push(listener); @@ -54,90 +55,32 @@ export default class Environment { object = this; } - const resolveFieldMeta = this.__resolveFieldMeta__; - const resolvedPath = resolvePath(object, path); - return resolveFieldMeta(resolvedPath); + return this.__resolveFieldMeta__(resolveCanonicalPath(object, path)); } unknownProperty(key) { - createEnvComputed(this, key, `__bound__.${findIndex(this.__bound__, key)}`); + defineProperty(this, key, broadcastingAlias(this, key)); return get(this, key); } setUnknownProperty(key, value) { - createEnvComputed(this, key, `__bound__.${findIndex(this.__bound__, key)}`); - set(this, key, value); - return get(this, key); - } -} - -/* - * Given a piece of data and an environment, returns a wrapped version of that value that - * will resolve any Binding instances against the given environment. - */ -export function wrap(data, env, key) { - // Persist the original environment key if we're re-wrapping a new one - const realKey = extractKey(data) || key; - if (Array.isArray(data) || data instanceof EnvironmentArray) { - return EnvironmentArray.create({ data, env, key: realKey }); - } else if ( - (data && typeof data === 'object' && !isHTMLSafe(data)) || - data instanceof EnvironmentData - ) { - return EnvironmentData.create({ data, env, key: realKey }); - } else { - return data; - } -} - -/* - * Given a wrapped piece of data, returns the underlying one. - */ -export function unwrap(data) { - if (data instanceof EnvironmentArray || data instanceof EnvironmentData) { - return data.__wrapped__; - } else { - return data; - } -} - -export function resolvePath(object, path) { - if (!path) return; - - const parts = path.split('.'); - const key = parts[parts.length - 1]; - const host = - parts.length > 1 ? get(object, parts.slice(0, -1).join('.')) : object; - if (host instanceof Environment) { - return ( - canonicalizeBinding( - host, - host.__bound__[findIndex(host.__bound__, key)][key] - ) || key - ); - } else if (host instanceof EnvironmentData) { - const canonicalized = canonicalizeBinding( - host.__env__, - host.__wrapped__[key] - ); - const hostKey = extractKey(host); - return canonicalized || (hostKey && `${hostKey}.${key}`); - } else if (host instanceof EnvironmentArray) { - throw new Error('Cannot canonicalize the path to an array element itself.'); + defineProperty(this, key, broadcastingAlias(this, key)); + return set(this, key, value); } } -function canonicalizeBinding(env, value) { - if (value instanceof Binding) { - return resolvePath(env, value.path.join('.')); - } else if ( - value instanceof EnvironmentData || - value instanceof EnvironmentArray - ) { - // We can wind up with wrapped values IN wrapped values in cases like `env.extend({ foo: env.get('bar') })` - // When this happens, we want to canonicalize on the original key - return resolvePath(env, extractKey(value)); - } +function broadcastingAlias(host, key) { + const fullPath = `__bound__.${findIndex(host.__bound__, key)}.${key}`; + return computed(fullPath, { + get() { + return get(this, fullPath); + }, + set(_, value) { + let result = set(this, fullPath, value); + this.trigger('change', key); + return result; + }, + }); } const hasProperty = Function.call.bind(Object.prototype.hasOwnProperty); diff --git a/ember-exclaim/src/-private/environment/array.js b/ember-exclaim/src/-private/environment/array.js deleted file mode 100644 index 5be6a83..0000000 --- a/ember-exclaim/src/-private/environment/array.js +++ /dev/null @@ -1,103 +0,0 @@ -import { set, get } from '@ember/object'; -import { A } from '@ember/array'; -import ArrayProxy from '@ember/array/proxy'; -import Binding from '../binding'; -import HelperSpec from '../helper-spec'; -import { wrap } from '../environment'; -import { extractKey } from './utils'; - -// eslint-disable-next-line ember/no-computed-properties-in-native-classes -import { defineProperty, computed } from '@ember/object'; - -/* - * Wraps an array, resolving any Bindings in it when requested to the corresponding - * paths in the given environment. - */ -export default class EnvironmentArray extends ArrayProxy { - static create({ data, env, key } = {}) { - let instance = super.create({ content: data }); - instance.__wrapped__ = - data instanceof EnvironmentArray ? data.__wrapped__ : A(data); - instance.__env__ = env; - instance.__key__ = key; - return instance; - } - - unknownProperty(key) { - if (/^\d+$/.test(key)) { - defineIndexProperty(this, key); - return get(this, key); - } else { - return (this[key] = undefined); - } - } - - setUnknownProperty(key, value) { - if (/^\d+$/.test(key)) { - defineIndexProperty(this, key); - set(this, key, value); - return get(this, key); - } else { - return (this[key] = value); - } - } - - // Overriding objectAt (rather than objectAtContent) in order to avoid - // the caching that ArrayProxy does in newer versions of Ember. - objectAt(index) { - const item = this.__wrapped__.objectAt(index); - if (item instanceof Binding) { - return get(this.__env__, item.path.join('.')); - } else if (item instanceof HelperSpec) { - return item.invoke(this.__env__); - } else { - let key = extractKey(this); - return wrap(item, this.__env__, key && `${key}.${index}`); - } - } - - replaceContent(index, amount, items) { - for (let i = 0; i < amount; i++) { - const item = this.__wrapped__.objectAt(i + index); - if (item instanceof Binding) { - set(this.__env__, item.path.join('.'), items[i]); - } else { - // Being lazy here and only `replace`-ing one at a time rather than doing the bookkeeping to group changes - this.__wrapped__.replace(i + index, 1, [items[i]]); - } - } - - if (amount > items.length) { - this.__wrapped__.replace(index + items.length, amount - items.length, []); - } else if (items.length > amount) { - this.__wrapped__.replace(index + items.length, 0, items.slice(amount)); - } - } - - toString() { - return `${this.__wrapped__}`; - } -} - -function defineIndexProperty(host, index) { - defineProperty( - host, - index, - computed('__wrapped__.[]', { - get() { - return host.__wrapped__.objectAt(index); - }, - set(_key, value) { - if (parseInt(index) + 1 > host.__wrapped__.length) { - const newWrappedArr = host.__wrapped__.slice(); - newWrappedArr[index] = value; - host.__wrapped__.setObjects(newWrappedArr); - } else { - host.__wrapped__.replace(index, 1, [value]); - } - - return value; - }, - }) - ); -} diff --git a/ember-exclaim/src/-private/environment/create-env-computed.js b/ember-exclaim/src/-private/environment/create-env-computed.js deleted file mode 100644 index 3015fd4..0000000 --- a/ember-exclaim/src/-private/environment/create-env-computed.js +++ /dev/null @@ -1,80 +0,0 @@ -import { alias } from '@ember/object/computed'; -import { computed, set, get, defineProperty } from '@ember/object'; -import Binding from '../binding'; -import HelperSpec from '../helper-spec'; -import { wrap } from '../environment'; -import { extractKey } from './utils'; - -/* - * For an object proxying some other content in an exclaim Environment, this function - * creates a computed property under the given key on that object that will handle - * resolution and traversal of exclaim Bindings. - * - * The `valueRoot` parameter is the key on the host under which the proxied content is present. - * That is, if I call `get(host, 'foo')`, the computed will ultimately end up returning - * get(host, valueRoot + '.foo'). - * - * The `envRoot` parameter is the key on the host under exclaim Bindings will be resolved. - * That is, if I get a value on the host that turns out to be a Binding, the ultimate returned - * value will be the value at that Binding's path on whatever object is in envRoot. (Note that - * Environment instances are their own binding resolution source, so they have no envRoot.) - */ -export default function createComputed(host, key, valueRoot, envRoot) { - const fullHostKey = `${valueRoot}.${key}`; - const result = get(host, fullHostKey); - const env = envRoot ? get(host, envRoot) : host; - - if (result instanceof Binding) { - // If it's a Binding, we can just return an alias for the given value on the environment - defineProperty(host, key, alias(envPath(envRoot, result))); - } else if (result instanceof HelperSpec) { - // If it's a helper we set up a computed that reflects its calculated result - defineProperty( - host, - key, - computed(...result.bindings.map((binding) => envPath(envRoot, binding)), { - get() { - return result.invoke(env); - }, - - set(key, value) { - return value; - }, - }) - ); - } else { - // Otherwise, we depend on the value of that key on the host object - const hostKey = extractKey(host); - const fullEnvKey = hostKey ? `${hostKey}.${key}` : key; - defineProperty( - host, - key, - computed(...determineDependentKeys(result, key, valueRoot, envRoot), { - get() { - return wrap(get(host, fullHostKey), env, fullEnvKey); - }, - set(key, value) { - set(host, fullHostKey, value); - env.trigger('change', fullEnvKey); - return wrap(get(host, fullHostKey), env, fullEnvKey); - }, - }) - ); - } -} - -// For arrays containing bindings, the calculated array value itself depends on all those bound paths -function determineDependentKeys(value, key, valueRoot, envRoot) { - if (!Array.isArray(value)) { - return [`${valueRoot}.${key}`]; - } else { - const bindings = value.filter((element) => element instanceof Binding); - const bindingKeys = bindings.map((binding) => envPath(envRoot, binding)); - return [`${valueRoot}.${key}.[]`, ...bindingKeys]; - } -} - -function envPath(envRoot, binding) { - const bindingPath = binding.path.join('.'); - return envRoot ? `${envRoot}.${bindingPath}` : bindingPath; -} diff --git a/ember-exclaim/src/-private/environment/data.js b/ember-exclaim/src/-private/environment/data.js deleted file mode 100644 index e0daefb..0000000 --- a/ember-exclaim/src/-private/environment/data.js +++ /dev/null @@ -1,40 +0,0 @@ -import { set, get } from '@ember/object'; -import createEnvComputed from './create-env-computed'; - -/* - * Wraps an object, resolving any Bindings in it when requested to the corresponding - * paths in the given environment. - */ -export default class EnvironmentData { - static create({ data, env, key }) { - let instance = new EnvironmentData(); - instance.__wrapped__ = - data instanceof EnvironmentData ? data.__wrapped__ : data; - instance.__env__ = env; - instance.__key__ = key; - return instance; - } - - get(key) { - return get(this, key); - } - - set(key, value) { - return set(this, key, value); - } - - unknownProperty(key) { - createEnvComputed(this, key, '__wrapped__', '__env__'); - return get(this, key); - } - - setUnknownProperty(key, value) { - createEnvComputed(this, key, '__wrapped__', '__env__'); - set(this, key, value); - return get(this, key); - } - - toString() { - return `${this.__wrapped__}`; - } -} diff --git a/ember-exclaim/src/-private/environment/utils.js b/ember-exclaim/src/-private/environment/utils.js deleted file mode 100644 index 817c593..0000000 --- a/ember-exclaim/src/-private/environment/utils.js +++ /dev/null @@ -1,5 +0,0 @@ -export function extractKey(object) { - if (object && typeof object === 'object' && '__key__' in object) { - return object.__key__; - } -} diff --git a/ember-exclaim/src/-private/paths.js b/ember-exclaim/src/-private/paths.js new file mode 100644 index 0000000..f7f96a7 --- /dev/null +++ b/ember-exclaim/src/-private/paths.js @@ -0,0 +1,48 @@ +import { get } from '@ember/object'; +import Environment from './environment'; + +const paths = new WeakMap(); + +/** + * Notes, for a given key on a given object, the source environment + * and path where the value for that key originates. + */ +export function recordCanonicalPath(object, key, env, path) { + let pathsForObject = paths.get(object); + if (!pathsForObject) { + pathsForObject = new Map(); + paths.set(object, pathsForObject); + } + pathsForObject.set(key, { env, path }); +} + +/** + * Returns the known environment and source path within it where + * the field at the path on the given object originates, if known. + */ +export function resolveCanonicalPath(object, path) { + let parts = path.split('.'); + let current = object; + // If we're starting from an environment to begin with, then the given + // path could be the canonical one on its own; otherwise if it's just + // on a random object, we won't know what we're looking at until we first + // encounter a bound field. + let fullCanonicalPath = object instanceof Environment ? [] : undefined; + + while (parts.length) { + if (!current) return; + + let key = parts.shift(); + let canonical = paths.get(current)?.get(key); + if (canonical) { + current = canonical.env; + parts.unshift(...canonical.path.split('.')); + fullCanonicalPath = []; + } else { + current = get(current, key); + fullCanonicalPath?.push(key); + } + } + + return fullCanonicalPath?.join('.'); +} diff --git a/ember-exclaim/src/-private/helper-spec.js b/ember-exclaim/src/-private/ui-spec.js similarity index 68% rename from ember-exclaim/src/-private/helper-spec.js rename to ember-exclaim/src/-private/ui-spec.js index 31c083e..137c9f5 100644 --- a/ember-exclaim/src/-private/helper-spec.js +++ b/ember-exclaim/src/-private/ui-spec.js @@ -1,7 +1,22 @@ -import Binding from './binding'; -import { wrap } from './environment'; +export class Binding { + constructor(path) { + this.path = path.split('.'); + } +} + +export class ComponentSpec { + constructor(component, config, meta) { + this.component = component; + this.config = config; + this.meta = meta; + } + + resolveConfig(env) { + return env.bind(this.config); + } +} -export default class HelperSpec { +export class HelperSpec { constructor(helper, config, meta) { this.helper = helper; this.config = config; @@ -11,7 +26,7 @@ export default class HelperSpec { invoke(env) { let { helper, config } = this; - return helper(wrap(config, env), env); + return helper(env.bind(config), env); } } diff --git a/ember-exclaim/src/components/exclaim-component.hbs b/ember-exclaim/src/components/exclaim-component.hbs index 874691e..d03f0c6 100644 --- a/ember-exclaim/src/components/exclaim-component.hbs +++ b/ember-exclaim/src/components/exclaim-component.hbs @@ -1,11 +1,11 @@ {{#component @wrapper - componentSpec=this.unwrappedSpec + componentSpec=@componentSpec env=this.effectiveEnv config=this.resolvedConfig }} {{#component - @componentSpec.path + @componentSpec.component config=this.resolvedConfig env=this.effectiveEnv as |componentSpec overrideEnv| @@ -17,4 +17,4 @@ wrapper=@wrapper ~}} {{/component}} -{{/component}} +{{/component}} \ No newline at end of file diff --git a/ember-exclaim/src/components/exclaim-component.js b/ember-exclaim/src/components/exclaim-component.js index 59bb5e3..e78053c 100644 --- a/ember-exclaim/src/components/exclaim-component.js +++ b/ember-exclaim/src/components/exclaim-component.js @@ -1,6 +1,5 @@ import { computed, get } from '@ember/object'; import Component from '@ember/component'; -import { unwrap } from '../-private/environment'; export default Component.extend({ tagName: '', @@ -8,19 +7,14 @@ export default Component.extend({ componentSpec: null, env: null, - unwrappedSpec: computed('componentSpec', function () { - return unwrap(get(this, 'componentSpec')); - }), - effectiveEnv: computed('env', 'overrideEnv', function () { return get(this, 'overrideEnv') || get(this, 'env'); }), - resolvedConfig: computed('unwrappedSpec', 'effectiveEnv', function () { - const unwrappedSpec = get(this, 'unwrappedSpec'); + resolvedConfig: computed('componentSpec', 'effectiveEnv', function () { + const componentSpec = get(this, 'componentSpec'); return ( - unwrappedSpec.resolveConfig && - unwrappedSpec.resolveConfig(get(this, 'effectiveEnv')) + componentSpec && componentSpec.resolveConfig(get(this, 'effectiveEnv')) ); }), }); diff --git a/ember-exclaim/src/index.js b/ember-exclaim/src/index.js index 38826bc..413d21b 100644 --- a/ember-exclaim/src/index.js +++ b/ember-exclaim/src/index.js @@ -1 +1,5 @@ -export { wrap, unwrap } from './-private/environment'; +export { ComponentSpec, HelperSpec } from './-private/ui-spec.js'; + +export function unwrap(value) { + return value; +} diff --git a/package.json b/package.json index b54603c..50728b1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test-app" ], "volta": { - "node": "16.20.1", + "node": "20.11.1", "yarn": "1.22.19" } } diff --git a/playground-app/app/components/exclaim-components/each/component.js b/playground-app/app/components/exclaim-components/each/component.js index 3170cd1..13077fc 100644 --- a/playground-app/app/components/exclaim-components/each/component.js +++ b/playground-app/app/components/exclaim-components/each/component.js @@ -1,4 +1,4 @@ -import { computed, get } from '@ember/object'; +import { computed } from '@ember/object'; import Component from '@ember/component'; export const NAME = 'each'; @@ -25,13 +25,8 @@ export default Component.extend({ tagName: '', items: computed('config.{items.[],yield}', 'env', function () { - const items = get(this, 'config.items'); - - if (items) { - const env = get(this, 'env'); - const key = get(this, 'config.yield'); - return items.map((item) => env.extend({ [key]: item })); - } - return; + return this.config.items?.map((item) => + this.env.extend({ [this.config.yield]: item }) + ); }), }); diff --git a/playground-app/app/components/exclaim-components/input/component.js b/playground-app/app/components/exclaim-components/input/component.js index 892aa3a..9714ca5 100644 --- a/playground-app/app/components/exclaim-components/input/component.js +++ b/playground-app/app/components/exclaim-components/input/component.js @@ -1,4 +1,4 @@ -import { computed, get } from '@ember/object'; +import { computed } from '@ember/object'; import Component from '@ember/component'; export const NAME = 'input'; @@ -31,6 +31,6 @@ export default Component.extend({ tagName: '', type: computed('config.type', function () { - return get(this, 'config.type') || 'text'; + return this.config.type ?? 'text'; }), }); diff --git a/playground-app/app/components/exclaim-components/let/component.js b/playground-app/app/components/exclaim-components/let/component.js index ed2627a..cc8403f 100644 --- a/playground-app/app/components/exclaim-components/let/component.js +++ b/playground-app/app/components/exclaim-components/let/component.js @@ -1,6 +1,5 @@ -import { computed, get } from '@ember/object'; +import { computed } from '@ember/object'; import Component from '@ember/component'; -import { unwrap } from 'ember-exclaim'; export const NAME = 'let'; export const DESCRIPTION = 'A construct for binding values in child components'; @@ -22,8 +21,6 @@ export default Component.extend({ tagName: '', boundEnv: computed('config.bindings', 'env', function () { - const env = get(this, 'env'); - const bindings = unwrap(get(this, 'config.bindings')); - return bindings ? env.extend(bindings) : env; + return this.env.extend(this.config.bindings ?? {}); }), }); diff --git a/playground-app/app/components/exclaim-components/table/component.js b/playground-app/app/components/exclaim-components/table/component.js index 926e9b2..78e219c 100644 --- a/playground-app/app/components/exclaim-components/table/component.js +++ b/playground-app/app/components/exclaim-components/table/component.js @@ -1,4 +1,4 @@ -import { computed, get } from '@ember/object'; +import { computed } from '@ember/object'; import Component from '@ember/component'; export const NAME = 'table'; @@ -31,13 +31,8 @@ export default Component.extend({ tagName: '', items: computed('config.{items.[],yield}', 'env', function () { - const items = get(this, 'config.items'); - - if (items) { - const env = get(this, 'env'); - const key = get(this, 'config.yield'); - return items.map((item) => env.extend({ [key]: item })); - } - return; + return this.config.items?.map((item) => + this.env.extend({ [this.config.yield]: item }) + ); }), }); diff --git a/playground-app/app/utils/exclaim-helpers/if.js b/playground-app/app/utils/exclaim-helpers/if.js index aa24a63..ab84584 100644 --- a/playground-app/app/utils/exclaim-helpers/if.js +++ b/playground-app/app/utils/exclaim-helpers/if.js @@ -1,5 +1,3 @@ -import { get } from '@ember/object'; - export const NAME = 'if'; export const DESCRIPTION = 'A construct for rendering one thing or another'; export const SHORTHAND_PROPERTY = 'condition'; @@ -21,9 +19,9 @@ export const PROPERTIES = [ ]; export default (config) => { - if (get(config, 'condition')) { - return get(config, 'then'); + if (config.condition) { + return config.then; } else { - return get(config, 'else'); + return config.else; } }; diff --git a/playground-app/app/utils/exclaim-helpers/join.js b/playground-app/app/utils/exclaim-helpers/join.js index e887438..f44050c 100644 --- a/playground-app/app/utils/exclaim-helpers/join.js +++ b/playground-app/app/utils/exclaim-helpers/join.js @@ -1,5 +1,3 @@ -import { get } from '@ember/object'; - export const NAME = 'join'; export const DESCRIPTION = 'Joins an array of values into a single string'; export const SHORTHAND_PROPERTY = 'items'; @@ -16,7 +14,5 @@ export const PROPERTIES = [ ]; export default (config) => { - let items = get(config, 'items'); - let separator = get(config, 'separator') || ''; - return items ? items.toArray().join(separator) : ''; + return config.items?.join(config.separator ?? ''); }; diff --git a/test-app/tests/integration/environment-test.js b/test-app/tests/integration/environment-test.js new file mode 100644 index 0000000..7d4473d --- /dev/null +++ b/test-app/tests/integration/environment-test.js @@ -0,0 +1,364 @@ +import Component from '@glimmer/component'; +import { A } from '@ember/array'; +import { set, get } from '@ember/object'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; +import { htmlSafe } from '@ember/template'; +import { setupRenderingTest } from 'ember-qunit'; + +module('Integration | environment', function (hooks) { + setupRenderingTest(hooks); + + function exclaimTest(name, { ui, env, resolveFieldMeta, implementationMap }) { + // eslint-disable-next-line qunit/require-expect + test(name, async function (assert) { + let deferred = {}; + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + this.env = env; + this.ui = ui; + this.resolveFieldMeta = resolveFieldMeta; + this.implementationMap = { + validate: { + shorthandProperty: 'callback', + componentPath: class extends Component { + constructor(owner, args) { + super(owner, args); + setTimeout(() => + this.runCallback().then(deferred.resolve, deferred.reject) + ); + } + + async runCallback() { + return this.args.config.callback.call(this.args, assert); + } + }, + }, + ...implementationMap, + }; + + await render(hbs` + + `); + + await deferred.promise; + }); + } + + exclaimTest('simple lookups', { + env: { foo: 'bar' }, + ui: { + async $validate(assert) { + assert.strictEqual(get(this.env, 'foo'), 'bar'); + + set(this.env, 'foo', 'baz'); + assert.strictEqual(get(this.env, 'foo'), 'baz'); + }, + }, + }); + + exclaimTest('simple array lookups', { + env: { foo: ['bar', 'baz'] }, + ui: { + async $validate(assert) { + assert.strictEqual(get(this.env, 'foo.0'), 'bar'); + assert.strictEqual(get(this.env, 'foo.1'), 'baz'); + }, + }, + }); + + exclaimTest('set unknown property', { + env: { foo: ['bar'] }, + ui: { + async $validate(assert) { + set(this.env, 'baz', 'bax'); + assert.strictEqual(get(this.env, 'baz'), 'bax'); + assert.strictEqual(get(this.env, 'foo.length'), 1); + set(this.env, 'foo.3', 'qux'); + assert.strictEqual(get(this.env, 'foo.3'), 'qux'); + assert.strictEqual(get(this.env, 'foo.length'), 4); + }, + }, + }); + + exclaimTest('array mutation', { + env: { foo: ['bar', 'baz'] }, + ui: { + bound: { $bind: 'foo' }, + async $validate(assert) { + set(this.env, 'foo.1', 'oops'); + assert.strictEqual(this.config.bound[1], 'oops'); + assert.strictEqual(get(this.env, 'foo.1'), 'oops'); + set(this.config.bound, '0', 'oops again'); + assert.strictEqual(get(this.env, 'foo.0'), 'oops again'); + }, + }, + }); + + exclaimTest('HTML-safe strings', { + env: { foo: htmlSafe('hello') }, + ui: { + async $validate(assert) { + assert.deepEqual(get(this.env, 'foo'), htmlSafe('hello')); + }, + }, + }); + + exclaimTest('simple binding resolution', { + env: { foo: 'bar' }, + ui: { + baz: { $bind: 'foo' }, + async $validate(assert) { + assert.strictEqual(get(this.config, 'baz'), 'bar'); + + set(this.env, 'foo', 'qux'); + assert.strictEqual(get(this.config, 'baz'), 'qux'); + + set(this.config, 'baz', 'fizz'); + assert.strictEqual(get(this.env, 'foo'), 'fizz'); + }, + }, + }); + + exclaimTest('complex binding resolution', { + env: { x: { y: 'foo', z: 'bar' } }, + resolveFieldMeta: (path) => path, + ui: { + other: { $bind: 'x' }, + stillMore: { $bind: 'x.z' }, + $validate(assert) { + assert.strictEqual(this.config.stillMore, 'bar'); + assert.strictEqual(this.env.metaForField('x.y'), 'x.y'); + assert.strictEqual(this.env.metaForField(this.config, 'other'), 'x'); + assert.strictEqual( + this.env.metaForField(this.config, 'stillMore'), + 'x.z' + ); + + set(this.env, 'x.z', 123); + assert.strictEqual(this.config.other.z, 123); + assert.strictEqual(this.config.stillMore, 123); + + set(this.config, 'other.z', 234); + assert.strictEqual(this.config.other.z, 234); + assert.strictEqual(this.config.stillMore, 234); + + set(this.config, 'stillMore', 345); + assert.strictEqual(this.config.other.z, 345); + assert.strictEqual(this.config.stillMore, 345); + }, + }, + }); + + exclaimTest('value reference resolution', { + env: { foo: 'bar' }, + resolveFieldMeta: (path) => path, + ui: { + value: { key: { $bind: 'foo' } }, + async $validate(assert) { + const value = this.config.value; + assert.strictEqual(get(value, 'key'), 'bar'); + assert.strictEqual(this.env.metaForField(value, 'key'), 'foo'); + + set(value, 'key', 'ok'); + assert.strictEqual(get(this.env, 'foo'), 'ok'); + }, + }, + }); + + exclaimTest('chained reference resolution', { + env: { foo: 'bar' }, + resolveFieldMeta: (path) => path, + ui: { + value: { key: { child: { $bind: 'foo' } } }, + async $validate(assert) { + const value = this.config.value; + const subvalue = get(value, 'key'); + assert.strictEqual(get(subvalue, 'child'), 'bar'); + + assert.strictEqual(this.env.metaForField(value, 'key'), undefined); + assert.strictEqual(this.env.metaForField(value, 'key.child'), 'foo'); + assert.strictEqual(this.env.metaForField(subvalue, 'child'), 'foo'); + + set(subvalue, 'child', 321); + assert.strictEqual(get(value, 'key.child'), 321); + assert.strictEqual(get(this.env, 'foo'), 321); + + set(this.env, 'foo', 999); + assert.strictEqual(get(subvalue, 'child'), 999); + }, + }, + }); + + exclaimTest('array values', { + env: { array: A([1, 2, 3]) }, + ui: { + ref: { array: { $bind: 'array' } }, + async $validate(assert) { + const ref = this.config.ref; + assert.deepEqual(get(this.env, 'array'), [1, 2, 3]); + assert.deepEqual(get(ref, 'array'), [1, 2, 3]); + + const envArray = get(this.env, 'array'); + const refArray = get(ref, 'array'); + assert.strictEqual(envArray[1], 2); + assert.strictEqual(refArray[1], 2); + + envArray.replace(1, 1, [100]); + assert.deepEqual(envArray, [1, 100, 3]); + assert.deepEqual(refArray, [1, 100, 3]); + + envArray.pushObject(4); + assert.deepEqual(envArray, [1, 100, 3, 4]); + assert.deepEqual(refArray, [1, 100, 3, 4]); + + refArray.shiftObject(); + assert.deepEqual(envArray, [100, 3, 4]); + assert.deepEqual(refArray, [100, 3, 4]); + }, + }, + }); + + exclaimTest('environment extension', { + env: { a: 'bar' }, + ui: { + async $validate(assert) { + const base = this.env; + const ext1 = base.extend({ b: 'qux' }); + const ext2 = base.extend({ a: 'newbar' }); + + assert.strictEqual(get(base, 'a'), 'bar'); + assert.strictEqual(get(base, 'b'), undefined); + + assert.strictEqual(get(ext1, 'a'), 'bar'); + assert.strictEqual(get(ext1, 'b'), 'qux'); + + assert.strictEqual(get(ext2, 'a'), 'newbar'); + assert.strictEqual(get(ext2, 'b'), undefined); + + // Writing to an inherited key updates at the level it was inherited from + set(ext1, 'a', 'x'); + assert.strictEqual(get(base, 'a'), 'x'); + assert.strictEqual(get(ext1, 'a'), 'x'); + assert.strictEqual(get(ext2, 'a'), 'newbar'); + + // Writing to an overridden key updates at the level of the override + set(ext2, 'a', 'y'); + assert.strictEqual(get(base, 'a'), 'x'); + assert.strictEqual(get(ext1, 'a'), 'x'); + assert.strictEqual(get(ext2, 'a'), 'y'); + + // Writing a previously-nonexistent key sets it in the leaf environment + set(ext1, 'c', 'newvalue'); + assert.strictEqual(get(base, 'c'), undefined); + assert.strictEqual(get(ext1, 'c'), 'newvalue'); + assert.strictEqual(get(ext2, 'c'), undefined); + + // Writing a previously-nonexistent key in the base propagates down + set(base, 'd', 'everywhere'); + assert.strictEqual(get(base, 'd'), 'everywhere'); + assert.strictEqual(get(ext1, 'd'), 'everywhere'); + assert.strictEqual(get(ext2, 'd'), 'everywhere'); + }, + }, + }); + + exclaimTest('path resolution', { + env: { + root: { + super: { + extra: { + nested: true, + }, + }, + }, + }, + resolveFieldMeta: (path) => path, + ui: { + nested: { + pointsToRoot: { $bind: 'root' }, + ownKey: 'ownValue', + }, + $validate(assert) { + assert.strictEqual(this.env.metaForField('nonexistent'), 'nonexistent'); + assert.strictEqual(this.env.metaForField('root'), 'root'); + assert.strictEqual( + this.env.metaForField(this.config, 'nested.ownKey'), + undefined + ); + assert.strictEqual( + this.env.metaForField(this.config, 'nested.pointsToRoot'), + 'root' + ); + assert.strictEqual( + this.env.metaForField( + this.config, + 'nested.pointsToRoot.super.extra.nested' + ), + 'root.super.extra.nested' + ); + + const subenv = this.env.extend({ + subValue: 'hello', + upReference: this.config.nested, + }); + + assert.strictEqual( + this.env.metaForField(subenv, 'upReference.pointsToRoot'), + 'root' + ); + assert.strictEqual( + this.env.metaForField(subenv, 'subValue'), + 'subValue' + ); + }, + }, + }); + + exclaimTest('helper invocation', { + implementationMap: { + shout: { + helper: (config) => config.word.toUpperCase(), + shorthandProperty: 'word', + }, + reverse: { + helper: (config) => config.word.split('').reverse().join(''), + shorthandProperty: 'word', + }, + length: { + helper: (config) => config.word.length, + shorthandProperty: 'word', + }, + }, + env: { + foo: 'bar', + }, + ui: { + shouty: { $shout: { $bind: 'foo' } }, + array: [1, 2, { $length: { $bind: 'foo' } }], + nested: { $shout: { $reverse: { $bind: 'foo' } } }, + $validate(assert) { + assert.strictEqual(get(this.env, 'foo'), 'bar'); + assert.strictEqual(this.config.shouty, 'BAR'); + assert.strictEqual(this.config.nested, 'RAB'); + assert.deepEqual(this.config.array, [1, 2, 3]); + + set(this.env, 'foo', 'ok'); + + assert.strictEqual(get(this.env, 'foo'), 'ok'); + assert.strictEqual(this.config.shouty, 'OK'); + assert.strictEqual(this.config.nested, 'KO'); + assert.deepEqual(this.config.array, [1, 2, 2]); + }, + }, + }); +}); diff --git a/test-app/tests/integration/exclaim-ui-test.js b/test-app/tests/integration/exclaim-ui-test.js index d48e5c1..4beae8f 100644 --- a/test-app/tests/integration/exclaim-ui-test.js +++ b/test-app/tests/integration/exclaim-ui-test.js @@ -43,9 +43,7 @@ module('Integration | Component | exclaim-ui', function (hooks) { set(this, 'implementationMap.join', { shorthandProperty: 'items', helper: (config) => { - let items = get(config, 'items').toArray(); - let separator = get(config, 'separator') || ', '; - return items.join(separator); + return config.items.join(config.separator ?? ', '); }, }); @@ -278,7 +276,7 @@ module('Integration | Component | exclaim-ui', function (hooks) { set(this, 'ui', { $component: 'parent-component', - items: [{ $bind: 'data.a' }, { $bind: 'data.b' }], + items: [{ value: { $bind: 'data.a' } }, { value: { $bind: 'data.b' } }], child: { $component: 'child-component', value: { $bind: 'item.value' }, @@ -291,22 +289,22 @@ module('Integration | Component | exclaim-ui', function (hooks) { set(this, 'env', { data: { - a: { value: 'hello' }, - b: { value: 'goodbye' }, + a: 'hello', + b: 'goodbye', }, }); await this.renderUI(); assert.strictEqual(this.element.textContent, 'Invalid.Invalid.'); - run(() => this.set('env.data.a.value', 'DATA.A.VALUE')); - assert.strictEqual(this.element.textContent, 'DATA.A.VALUEInvalid.'); + run(() => this.set('env.data.a', 'DATA.A')); + assert.strictEqual(this.element.textContent, 'DATA.AInvalid.'); - run(() => this.set('env.data.b.value', 'DATA.A.VALUE')); - assert.strictEqual(this.element.textContent, 'DATA.A.VALUEInvalid.'); + run(() => this.set('env.data.b', 'DATA.A')); + assert.strictEqual(this.element.textContent, 'DATA.AInvalid.'); - run(() => this.set('env.data.b.value', 'DATA.B.VALUE')); - assert.strictEqual(this.element.textContent, 'DATA.A.VALUEDATA.B.VALUE'); + run(() => this.set('env.data.b', 'DATA.B')); + assert.strictEqual(this.element.textContent, 'DATA.ADATA.B'); }); test('it renders the wrapper component around every extensible component', async function (assert) { diff --git a/test-app/tests/unit/build-spec-processor-test.js b/test-app/tests/unit/build-spec-processor-test.js deleted file mode 100644 index 79081fb..0000000 --- a/test-app/tests/unit/build-spec-processor-test.js +++ /dev/null @@ -1,127 +0,0 @@ -import { module, test } from 'qunit'; -import buildSpecProcessor from 'ember-exclaim/-private/build-spec-processor'; -import Binding from 'ember-exclaim/-private/binding'; -import ComponentSpec from 'ember-exclaim/-private/component-spec'; -import HelperSpec from 'ember-exclaim/-private/helper-spec'; - -module('Unit | build-spec-processor', function () { - test('processing valid config', function (assert) { - let implementationMap = { foo: { componentPath: 'components/foo' } }; - let processor = buildSpecProcessor({ implementationMap }); - let input = { - $component: 'foo', - value: { - $bind: 'bar', - }, - }; - - let result = processor(input); - assert.deepEqual( - result, - new ComponentSpec('components/foo', { value: new Binding('bar') }) - ); - }); - - test('processing an empty binding', function (assert) { - let implementationMap = {}; - let processor = buildSpecProcessor({ implementationMap }); - let input = { - $component: 'foo', - value: { - $bind: '', - }, - }; - - assert.throws(() => processor(input), 'Invalid binding: ""'); - }); - - test('processing a component with shorthand', function (assert) { - let implementationMap = { - foo: { - componentPath: 'components/foo', - shorthandProperty: 'value', - }, - }; - - let processor = buildSpecProcessor({ implementationMap }); - let input = { $foo: { $bind: 'bar' } }; - - let result = processor(input); - assert.deepEqual( - result, - new ComponentSpec('components/foo', { value: new Binding('bar') }) - ); - }); - - test('processing a component with meta', function (assert) { - let implementationMap = { - foo: { - componentPath: 'components/foo', - componentMeta: { - available: true, - }, - }, - }; - - let processor = buildSpecProcessor({ implementationMap }); - let input = { - $component: 'foo', - value: { - $bind: 'bar', - }, - }; - let result = processor(input); - assert.deepEqual( - result, - new ComponentSpec( - 'components/foo', - { value: new Binding('bar') }, - { available: true } - ) - ); - }); - - test('processing a helper with shorthand', function (assert) { - let helper = () => {}; - let implementationMap = { - foo: { - helper, - shorthandProperty: 'value', - }, - }; - - let processor = buildSpecProcessor({ implementationMap }); - let input = { $foo: { $bind: 'bar' } }; - - let result = processor(input); - assert.deepEqual( - result, - new HelperSpec(helper, { value: new Binding('bar') }) - ); - }); - - test('processing a helper with meta', function (assert) { - let helper = () => {}; - let implementationMap = { - foo: { - helper, - helperMeta: { - available: true, - }, - }, - }; - - let processor = buildSpecProcessor({ implementationMap }); - let input = { - $helper: 'foo', - value: { - $bind: 'bar', - }, - }; - let result = processor(input); - assert.deepEqual( - result, - new HelperSpec(helper, { value: new Binding('bar') }, { available: true }) - ); - }); -}); diff --git a/test-app/tests/unit/environment-test.js b/test-app/tests/unit/environment-test.js deleted file mode 100644 index d4741c3..0000000 --- a/test-app/tests/unit/environment-test.js +++ /dev/null @@ -1,344 +0,0 @@ -import { set, get } from '@ember/object'; -import { module, test } from 'qunit'; -import Binding from 'ember-exclaim/-private/binding'; -import HelperSpec from 'ember-exclaim/-private/helper-spec'; -import Environment, { - wrap, - resolvePath, -} from 'ember-exclaim/-private/environment'; -import { htmlSafe } from '@ember/template'; - -module('Unit | environment', function () { - test('simple lookups', function (assert) { - const env = new Environment({ foo: 'bar' }); - assert.strictEqual(get(env, 'foo'), 'bar'); - - set(env, 'foo', 'baz'); - assert.strictEqual(get(env, 'foo'), 'baz'); - }); - - test('simple array lookups', function (assert) { - const env = new Environment({ foo: ['bar', 'baz'] }); - assert.strictEqual(get(env, 'foo.0'), 'bar'); - assert.strictEqual(get(env, 'foo.1'), 'baz'); - }); - - test('set unknown property', function (assert) { - const env = new Environment({ foo: ['bar'] }); - set(env, 'baz', 'bax'); - assert.strictEqual(get(env, 'baz'), 'bax'); - assert.strictEqual(get(env, 'foo.length'), 1); - set(env, 'foo.3', 'qux'); - assert.strictEqual(get(env, 'foo.3'), 'qux'); - assert.strictEqual(get(env, 'foo.length'), 4); - }); - - test('array mutation', function (assert) { - const foo = ['bar', 'baz']; - const env = new Environment({ foo }); - set(env, 'foo.1', 'oops'); - assert.strictEqual(foo[1], 'oops'); - assert.strictEqual(get(env, 'foo.1'), 'oops'); - set(env, 'foo.0', 'oops again'); - assert.strictEqual(get(foo, 'firstObject'), 'oops again'); - }); - - test('HTML-safe strings', function (assert) { - const env = new Environment({ foo: htmlSafe('hello') }); - assert.deepEqual(get(env, 'foo'), htmlSafe('hello')); - }); - - test('simple binding resolution', function (assert) { - const env = new Environment({ foo: 'bar', baz: new Binding('foo') }); - assert.strictEqual(get(env, 'baz'), 'bar'); - - set(env, 'foo', 'qux'); - assert.strictEqual(get(env, 'baz'), 'qux'); - - set(env, 'baz', 'fizz'); - assert.strictEqual(get(env, 'foo'), 'fizz'); - }); - - test('complex binding resolution', function (assert) { - const env = new Environment({ - x: { y: 'foo', z: 'bar' }, - other: new Binding('x'), - stillMore: new Binding('other.z'), - }); - assert.strictEqual(get(env, 'stillMore'), 'bar'); - assert.strictEqual(resolvePath(env, 'x.y'), 'x.y'); - assert.strictEqual(resolvePath(env, 'other'), 'x'); - assert.strictEqual(resolvePath(env, 'stillMore'), 'x.z'); - - set(env, 'x.z', 123); - assert.strictEqual(get(env, 'other.z'), 123); - assert.strictEqual(get(env, 'stillMore'), 123); - - set(env, 'other.z', 234); - assert.strictEqual(get(env, 'x.z'), 234); - assert.strictEqual(get(env, 'stillMore'), 234); - - set(env, 'stillMore', 345); - assert.strictEqual(get(env, 'x.z'), 345); - assert.strictEqual(get(env, 'other.z'), 345); - }); - - test('value reference resolution', function (assert) { - const env = new Environment({ foo: 'bar' }); - const value = wrap({ key: new Binding('foo') }, env); - assert.strictEqual(get(value, 'key'), 'bar'); - assert.strictEqual(resolvePath(value, 'key'), 'foo'); - - set(value, 'key', 'ok'); - assert.strictEqual(get(env, 'foo'), 'ok'); - }); - - test('chained reference resolution', function (assert) { - const env = new Environment({ foo: 'bar' }); - const value = wrap({ key: { child: new Binding('foo') } }, env); - const subvalue = get(value, 'key'); - assert.strictEqual(get(subvalue, 'child'), 'bar'); - - assert.strictEqual(resolvePath(value, 'key'), undefined); - assert.strictEqual(resolvePath(value, 'key.child'), 'foo'); - assert.strictEqual(resolvePath(subvalue, 'child'), 'foo'); - - set(subvalue, 'child', 321); - assert.strictEqual(get(value, 'key.child'), 321); - assert.strictEqual(get(env, 'foo'), 321); - - set(env, 'foo', 999); - assert.strictEqual(get(subvalue, 'child'), 999); - }); - - test('array values', function (assert) { - const env = new Environment({ foo: 2, array: [1, new Binding('foo'), 3] }); - const ref = wrap({ array: new Binding('array') }, env); - assert.deepEqual(get(env, 'array').toArray(), [1, 2, 3]); - assert.deepEqual(get(ref, 'array').toArray(), [1, 2, 3]); - - const envArray = get(env, 'array'); - const refArray = get(ref, 'array'); - assert.strictEqual(envArray.objectAt(1), 2); - assert.strictEqual(refArray.objectAt(1), 2); - - envArray.replace(1, 1, [100]); - assert.deepEqual(envArray.toArray(), [1, 100, 3]); - assert.deepEqual(refArray.toArray(), [1, 100, 3]); - - envArray.pushObject(4); - assert.deepEqual(envArray.toArray(), [1, 100, 3, 4]); - assert.deepEqual(refArray.toArray(), [1, 100, 3, 4]); - - refArray.shiftObject(); - assert.deepEqual(envArray.toArray(), [100, 3, 4]); - assert.deepEqual(refArray.toArray(), [100, 3, 4]); - - set(env, 'foo', 0); - assert.deepEqual(envArray.toArray(), [0, 3, 4]); - assert.deepEqual(refArray.toArray(), [0, 3, 4]); - }); - - test('environment extension', function (assert) { - const base = new Environment({ a: 'bar' }); - const ext1 = base.extend({ b: 'qux' }); - const ext2 = base.extend({ a: 'newbar' }); - - assert.strictEqual(get(base, 'a'), 'bar'); - assert.strictEqual(get(base, 'b'), undefined); - - assert.strictEqual(get(ext1, 'a'), 'bar'); - assert.strictEqual(get(ext1, 'b'), 'qux'); - - assert.strictEqual(get(ext2, 'a'), 'newbar'); - assert.strictEqual(get(ext2, 'b'), undefined); - - // Writing to an inherited key updates at the level it was inherited from - set(ext1, 'a', 'x'); - assert.strictEqual(get(base, 'a'), 'x'); - assert.strictEqual(get(ext1, 'a'), 'x'); - assert.strictEqual(get(ext2, 'a'), 'newbar'); - - // Writing to an overridden key updates at the level of the override - set(ext2, 'a', 'y'); - assert.strictEqual(get(base, 'a'), 'x'); - assert.strictEqual(get(ext1, 'a'), 'x'); - assert.strictEqual(get(ext2, 'a'), 'y'); - - // Writing a previously-nonexistent key sets it in the leaf environment - set(ext1, 'c', 'newvalue'); - assert.strictEqual(get(base, 'c'), undefined); - assert.strictEqual(get(ext1, 'c'), 'newvalue'); - assert.strictEqual(get(ext2, 'c'), undefined); - - // Writing a previously-nonexistent key in the base propagates down - set(base, 'd', 'everywhere'); - assert.strictEqual(get(base, 'd'), 'everywhere'); - assert.strictEqual(get(ext1, 'd'), 'everywhere'); - assert.strictEqual(get(ext2, 'd'), 'everywhere'); - }); - - test('path resolution', function (assert) { - const env = new Environment({ - root: 'value', - nested: { - pointsToRoot: new Binding('root'), - ownKey: 'ownValue', - super: { - extra: { - nested: true, - }, - }, - }, - array: [ - new Binding('nested'), - { - pointsToParent: new Binding('array'), - ownKey: 'ownKey', - }, - ], - }); - - assert.strictEqual(resolvePath(env, 'nonexistent'), 'nonexistent'); - assert.strictEqual(resolvePath(env, 'root'), 'root'); - assert.strictEqual(resolvePath(env, 'nested'), 'nested'); - assert.strictEqual(resolvePath(env, 'nested.ownKey'), 'nested.ownKey'); - assert.strictEqual(resolvePath(env, 'nested.pointsToRoot'), 'root'); - assert.strictEqual(resolvePath(env, 'array'), 'array'); - assert.strictEqual( - resolvePath(env, 'array.firstObject.ownKey'), - 'nested.ownKey' - ); - assert.strictEqual( - resolvePath(env, 'nested.super.extra.nested'), - 'nested.super.extra.nested' - ); - - const nested = get(env, 'nested'); - assert.strictEqual( - resolvePath(nested, 'nonexistent'), - 'nested.nonexistent' - ); - assert.strictEqual(resolvePath(nested, 'ownKey'), 'nested.ownKey'); - assert.strictEqual(resolvePath(nested, 'pointsToRoot'), 'root'); - - const array = get(env, 'array'); - assert.strictEqual(resolvePath(array, 'firstObject.pointsToRoot'), 'root'); - assert.strictEqual( - resolvePath(array, 'firstObject.ownKey'), - 'nested.ownKey' - ); - assert.strictEqual( - resolvePath(array, 'lastObject.ownKey'), - 'array.1.ownKey' - ); - assert.strictEqual( - resolvePath(array, 'lastObject.pointsToParent'), - 'array' - ); - assert.throws( - () => resolvePath(array, 'firstObject'), - /Cannot canonicalize the path to an array element/ - ); - assert.throws( - () => resolvePath(array, '0'), - /Cannot canonicalize the path to an array element/ - ); - - // A value (like components' `config`) that has bindings to the environment, but didn't itself come from there - const config = wrap( - { - abc: 123, - binding: new Binding('nested.ownKey'), - }, - env - ); - - assert.strictEqual(resolvePath(config, 'abc'), undefined); - assert.strictEqual(resolvePath(config, 'binding'), 'nested.ownKey'); - - const subenv = env.extend({ - subValue: 'hello', - // eslint-disable-next-line ember/avoid-leaking-state-in-ember-objects - upReference: new Binding('nested.ownKey'), - recycled: get(env, 'nested.super'), - }); - - assert.strictEqual(resolvePath(subenv, 'upReference'), 'nested.ownKey'); - assert.strictEqual(resolvePath(subenv, 'subValue'), 'subValue'); - assert.strictEqual(resolvePath(subenv, 'recycled'), 'nested.super'); - assert.strictEqual( - resolvePath(subenv, 'recycled.extra'), - 'nested.super.extra' - ); - }); - - test('metadata resolution', function (assert) { - const env = new Environment( - { - foo: 'bar', - baz: new Binding('foo'), - deep: { - own: 'key', - ref: new Binding('baz'), - }, - }, - (path) => { - return { tag: 'so meta', path }; - } - ); - - assert.deepEqual(env.metaForField('foo'), { tag: 'so meta', path: 'foo' }); - assert.deepEqual(env.metaForField('baz'), { tag: 'so meta', path: 'foo' }); - assert.deepEqual(env.metaForField('deep.ref'), { - tag: 'so meta', - path: 'foo', - }); - - const value = get(env, 'deep'); - assert.deepEqual(env.metaForField(value, 'own'), { - tag: 'so meta', - path: 'deep.own', - }); - assert.deepEqual(env.metaForField(value, 'ref'), { - tag: 'so meta', - path: 'foo', - }); - }); - - test('helper invocation', function (assert) { - const env = new Environment({ - foo: 'bar', - shouty: new HelperSpec((config) => get(config, 'word').toUpperCase(), { - word: new Binding('foo'), - }), - array: [ - 1, - 2, - new HelperSpec((config) => get(config, 'word.length'), { - word: new Binding('shouty'), - }), - ], - nested: new HelperSpec((config) => get(config, 'word').toUpperCase(), { - word: new HelperSpec( - (config) => get(config, 'word').split('').reverse().join(''), - { - word: new Binding('foo'), - } - ), - }), - }); - - assert.strictEqual(get(env, 'foo'), 'bar'); - assert.strictEqual(get(env, 'shouty'), 'BAR'); - assert.strictEqual(get(env, 'nested'), 'RAB'); - assert.deepEqual(get(env, 'array').toArray(), [1, 2, 3]); - - set(env, 'foo', 'ok'); - - assert.strictEqual(get(env, 'foo'), 'ok'); - assert.strictEqual(get(env, 'shouty'), 'OK'); - assert.strictEqual(get(env, 'nested'), 'KO'); - assert.deepEqual(get(env, 'array').toArray(), [1, 2, 2]); - }); -}); diff --git a/test-app/tests/unit/helper-spec-test.js b/test-app/tests/unit/helper-spec-test.js deleted file mode 100644 index 9f3b915..0000000 --- a/test-app/tests/unit/helper-spec-test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { module, test } from 'qunit'; -import Binding from 'ember-exclaim/-private/binding'; -import HelperSpec from 'ember-exclaim/-private/helper-spec'; - -module('Unit | helper-spec'); - -test('discovering bindings', function (assert) { - let config = { - foo: new Binding('foo'), - bar: [new Binding('bar[1]'), new Binding('bar[2]')], - baz: { - key: new Binding('value'), - children: [new Binding('a'), new Binding('b')], - }, - }; - - let spec = new HelperSpec(() => {}, config); - - assert.deepEqual( - spec.bindings.map((binding) => binding.path.join('')).sort(), - ['a', 'b', 'bar[1]', 'bar[2]', 'foo', 'value'] - ); -});