Skip to content

Commit

Permalink
Don't require get/unwrap when working with config and objects i…
Browse files Browse the repository at this point in the history
…n the environment (#58)

* Move "parsed spec" stuff into `ui-spec.js`

* `wrap` is gone; `unwrap` is a no-op

* Externalize canonical path tracking

* Introduce `bindComputeds` for wiring up `Bindings` in-place

* Rework `Environment` in terms of `bindComputeds`

* Update `ExclaimComponent` for new spec shape

* Rework existing `ExclaimUi` test

* Refactor unit tetsts into public-API-only integration tests

* Drop `get` and `unwrap` in playground components

* Pin Node to v20
  • Loading branch information
dfreeman authored Mar 14, 2024
1 parent 40ce45a commit 5477b24
Show file tree
Hide file tree
Showing 26 changed files with 603 additions and 889 deletions.
109 changes: 109 additions & 0 deletions ember-exclaim/src/-private/bind-computeds.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 0 additions & 5 deletions ember-exclaim/src/-private/binding.js

This file was deleted.

4 changes: 1 addition & 3 deletions ember-exclaim/src/-private/build-spec-processor.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 0 additions & 13 deletions ember-exclaim/src/-private/component-spec.js

This file was deleted.

105 changes: 24 additions & 81 deletions ember-exclaim/src/-private/environment.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
Expand All @@ -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);
Expand Down
103 changes: 0 additions & 103 deletions ember-exclaim/src/-private/environment/array.js

This file was deleted.

Loading

0 comments on commit 5477b24

Please sign in to comment.