Skip to content

Commit

Permalink
Eliminate the Environment class (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
dfreeman authored Mar 17, 2024
1 parent 5477b24 commit 66c5d30
Show file tree
Hide file tree
Showing 32 changed files with 4,744 additions and 1,029 deletions.
22 changes: 9 additions & 13 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.

# compiled output
dist/

# dependencies
node_modules/

# misc
/.env*
/.pnp*
/.pnpm-debug.log
/.sass-cache
.env*
.pnp*
.pnpm-debug.log
.sass-cache
.eslintcache
/connect.lock
/coverage/
/libpeerconnection.log
/npm-debug.log*
/testem.log
/yarn-error.log
coverage/
npm-debug.log*
yarn-error.log

# ember-try
/.node_modules.ember-try/
/package.json.ember-try
/package-lock.json.ember-try
/yarn.lock.ember-try
/pnpm-lock.ember-try.yaml
40 changes: 14 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,12 @@ And something like this would render an input that would update the underlying v

## Usage

The entry point to a UI powered by ember-exclaim is the `{{exclaim-ui}}` component. It accepts the following properties:
- `ui`: an object containing configuration for the UI that should be rendered
- `env`: a hash whose keys will be bindable from the `ui` config, to be read from and written to
- `implementationMap`: a mapping of names in the `ui` config to information about their backing implementations
- `resolveFieldMeta(path)`: an optional action that will be invoked if a component calls `env.metaForField(...)`
- `onChange(envKeyOfChangedValue)`: an optional action that will be invoked when a value in the `env` changes
- `wrapper`: an optional component or component name string that will wrap every rendered component in your UI configuration. The `wrapper` component will receive the unwrapped `ComponentSpec` as `spec` ([more on `ComponentSpec` here](ember-exclaim/src/-private/GLOSSARY.md)), the `Environment` as `env` and the component's resolved `config`.
- `resolveMeta(path)`: an optional action that will be invoked if a component calls `env.metaFor(...)`
The entry point to a UI powered by ember-exclaim is the `<ExclaimUi>` component. It accepts the following arguments:
- `@ui`: an object containing configuration for the UI that should be rendered
- `@env`: a hash whose keys will be bindable from the `ui` config, to be read from and written to
- `@implementationMap`: a mapping of names in the `ui` config to information about their backing implementations
- `@onChange(envPathOfChangedValue)`: an optional function that will be invoked when a value in the `env` changes
- `@wrapper`: an optional component that will wrap every rendered component in your UI configuration. The `wrapper` component will receive the `ComponentSpec` as `@spec` ([more on `ComponentSpec` here](ember-exclaim/src/-private/GLOSSARY.md)), the `Environment` as `@env` and the component's resolved `@config`.

Each of these things is described in further detail below.

Expand Down Expand Up @@ -162,40 +160,30 @@ Note that `$bind` works with paths, too, so `{ $bind: 'foo.bar' }` would access

### The Implementation Map

The `implementationMap` given to `{{exclaim-ui}}` dictates what components it can render. It should be a hash whose keys are the component and helper names available for use in the UI config. The value for each key should itself be a hash describing the component or helper with that name.
The `@implementationMap` given to `<ExclaimUi>` dictates what components it can render. It should be a hash whose keys are the component and helper names available for use in the UI config. The value for each key should itself be a hash describing the component or helper with that name.
- `componentPath` (for components): the name to the Ember component to be invoked when this exclaim-ui component is used in the config, as you'd give it to the `{{component}}` helper
- `helper` (for helper functions): a function that receives a `config` hash and `env` information and should return the output value for the helper
- `shorthandProperty` (optional for both helpers and components): the name of a property that should be populated when shorthand notation is used for this component or helper (see above)

### Metadata Resolution

The `env` property exposed to ember-exclaim components (see below for details) includes a `metaForField(object, key)` method that component implementations can use to discover more information about their bound values. For instance, an `$input` component might call `metaForField(this, 'config.value')` to discover validation rules for its bound value in order to display an error message to the user.

The `resolveFieldMeta` action on `{{exclaim-ui}}` designates how this metadata is discovered. It receives the full path in the environment of the value in question.

This action should return any relevant information available about the field at `valuePath`. Note that, if a component calls `metaForField` on a path that doesn't resolve to a field on the environment, the `resolveFieldMeta` action will not be invoked.

## Implementing Components

The [demo app](https://salsify.github.io/ember-exclaim) for this repo contains [a variety of simple component implementations](tests/dummy/app/components/exclaim-components) that you can use as a starting point for building your own.

An ember-exclaim component implementation will receive two properties when rendered: `config` and `env`.

### `config`
### `@config`

The `config` property of the implementing component will contain all other information supplied in the `$component` hash representing it in the UI config. Any `$bind` directives in that config will be automatically be resolved when they are `get` or `set`. As an example, consider a lightweight implementation of the `input` component mentioned above.
The `@config` argument of the implementing component will contain all other information supplied in the `$component` hash representing it in the UI config. Any `$bind` directives in that config will be automatically be resolved when they are `get` or `set`. As an example, consider a lightweight implementation of the `input` component mentioned above.

```hbs
<input type="text" value={{config.value}} oninput={{action (mut config.value) value='target.value'}}>
<input type="text" value={{@config.value}} oninput={{action (mut @config.value) value='target.value'}}>
```

When invoked as `{ "$component": "input", "value": {"$bind":"x"} }` with `x` in the environment having the value `'hello'`, this component will receive the equivalent of `{ value: 'hello' }` as its `config`, except that reading and writing `config.value` will redirect back to `x` on the environment.

### `env`
### `@env`

The `env` property will contain an object representing the environment that the component is being rendered in. This object has two methods:
- `extend(hash)`: can be used to produce a new environment based on the original that additionally contains the values from the given hash
- `metaForField(object, key)`: takes an object and key, resolves the canonical path for that key in the environment, and then retrieves metadata for that path according to any configured `resolveFieldMeta` action on the owning `{{exclaim-ui}}` component
The `@env` arg to components in the UI will contain the same `@env` value passed to `<ExclaimUi>` (or an extension of that value). Components should rarely need to access it directly, but it's available as a grab bag of shared state that all components in an instance of a UI have access to.

### Rendering Children

Expand All @@ -204,12 +192,12 @@ In many cases, components may want to accept configuration for subcomponents tha
For example, the [`vbox`](tests/dummy/app/components/exclaim-components/vbox) component in the demo application applies a class with `flex-flow: column` to its root element and then simply renders all its children directly beneath:

```hbs
{{#each config.children as |child|}}
{{#each @config.children as |child|}}
{{yield child}}
{{/each}}
```

By default, children will inherit the environment of their parent. This environment can be overridden by passing a new `env` value as a second parameter to `{{yield}}`, typically obtained by calling `extend` on the base environment (see above). Check the implementation of [`each`](tests/dummy/app/components/exclaim-components/each) and [`let`](tests/dummy/app/components/exclaim-components/let) in the demo app for examples of how this can be used.
By default, children will inherit the environment of their parent. This environment can be extended by passing a POJO with additional key/value pairs as a second parameter to `{{yield}}`. Check the implementation of [`each`](playground-app/app/components/exclaim-components/each) and [`let`](playground-app/app/components/exclaim-components/let) in the demo app for examples of how this can be used.

## Implementing Helpers

Expand Down
2 changes: 1 addition & 1 deletion config/ember-cli-update.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"packages": [
{
"name": "@embroider/addon-blueprint",
"version": "1.6.2",
"version": "2.14.0",
"blueprints": [
{
"name": "@embroider/addon-blueprint",
Expand Down
2 changes: 1 addition & 1 deletion ember-exclaim/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module.exports = {
{
files: [
'./.eslintrc.cjs',
'./.prettierrc.js',
'./.prettierrc.cjs',
'./.template-lintrc.cjs',
'./addon-main.cjs',
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

module.exports = {
plugins: ['prettier-plugin-ember-template-tag'],
singleQuote: true,
};
7 changes: 5 additions & 2 deletions ember-exclaim/babel.config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{
"plugins": [
"@embroider/addon-dev/template-colocation-plugin",
["@babel/plugin-proposal-decorators", { "version": "legacy" }],
"@babel/plugin-proposal-class-properties"
["babel-plugin-ember-template-compilation", {
"targetFormat": "hbs",
"transforms": []
}],
["module:decorator-transforms", { "runtime": { "import": "decorator-transforms/runtime" } }],
]
}
44 changes: 22 additions & 22 deletions ember-exclaim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,36 @@
"lint": "concurrently 'npm:lint:*(!fix)' --names 'lint:'",
"lint:fix": "concurrently 'npm:lint:*:fix' --names 'fix:'",
"lint:hbs": "ember-template-lint . --no-error-on-unmatched-pattern",
"lint:js": "eslint . --cache",
"lint:hbs:fix": "ember-template-lint . --fix --no-error-on-unmatched-pattern",
"lint:js": "eslint . --cache",
"lint:js:fix": "eslint . --fix",
"prepack": "rollup --config",
"start": "rollup --config --watch",
"test": "echo 'A v2 addon does not have tests, run tests in test-app'",
"prepack": "rollup --config"
"test": "echo 'A v2 addon does not have tests, run tests in test-app'"
},
"dependencies": {
"@embroider/addon-shim": "^1.0.0",
"botanist": "^1.3.0"
"@embroider/addon-shim": "^1.8.7",
"botanist": "^1.3.0",
"decorator-transforms": "^1.0.1"
},
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/eslint-parser": "^7.19.1",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-decorators": "^7.20.13",
"@babel/core": "^7.23.6",
"@babel/eslint-parser": "^7.23.3",
"@babel/runtime": "^7.17.0",
"@embroider/addon-dev": "^3.0.0",
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-node-resolve": "^15.1.0",
"concurrently": "^8.0.1",
"ember-template-lint": "^5.7.3",
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-ember": "^11.6.0",
"eslint-plugin-n": "^16.0.0",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.5.1",
"rollup": "^3.21.8",
"rollup-plugin-copy": "^3.4.0"
"@embroider/addon-dev": "^4.1.0",
"@rollup/plugin-babel": "^6.0.4",
"babel-plugin-ember-template-compilation": "^2.2.1",
"concurrently": "^8.2.2",
"ember-template-lint": "^5.13.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-ember": "^11.12.0",
"eslint-plugin-n": "^16.4.0",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.1.1",
"prettier-plugin-ember-template-tag": "^1.1.0",
"rollup": "^4.9.1",
"rollup-plugin-copy": "^3.5.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org"
Expand Down
21 changes: 10 additions & 11 deletions ember-exclaim/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { babel } from '@rollup/plugin-babel';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import copy from 'rollup-plugin-copy';
import { Addon } from '@embroider/addon-dev/rollup';

Expand All @@ -8,18 +7,18 @@ const addon = new Addon({
destDir: 'dist',
});

// Add extensions here, such as ts, gjs, etc that you may import
const extensions = ['.js'];

export default {
// This provides defaults that work well alongside `publicEntrypoints` below.
// You can augment this if you need to.
output: addon.output(),

plugins: [
// These are the modules that users should be able to import from your
// addon. Anything not listed here may get optimized away.
addon.publicEntrypoints(['**/*.js']),
// By default all your JavaScript modules (**/*.js) will be importable.
// But you are encouraged to tweak this to only cover the modules that make
// up your addon's public API. Also make sure your package.json#exports
// is aligned to the config here.
// See https://github.com/embroider-build/embroider/blob/main/docs/v2-faq.md#how-can-i-define-the-public-exports-of-my-addon
addon.publicEntrypoints(['components/**/*.js', 'index.js']),

// These are the modules that should get reexported into the traditional
// "app" tree. Things in here should also be in publicEntrypoints above, but
Expand All @@ -38,16 +37,16 @@ export default {
// By default, this will load the actual babel config from the file
// babel.config.json.
babel({
extensions,
extensions: ['.js', '.gjs'],
babelHelpers: 'bundled',
}),

// Allows rollup to resolve imports of files with the specified extensions
nodeResolve({ extensions }),

// Ensure that standalone .hbs files are properly integrated as Javascript.
addon.hbs(),

// Ensure that .gjs files are properly integrated as Javascript
addon.gjs(),

// addons are allowed to contain imports of .css files, which we want rollup
// to leave alone and keep in the published output.
addon.keepAssets(['**/*.css']),
Expand Down
14 changes: 7 additions & 7 deletions ember-exclaim/src/-private/build-spec-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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
Object.prototype.hasOwnProperty,
);

export default function buildSpecProcessor({ implementationMap }) {
Expand Down Expand Up @@ -30,7 +30,7 @@ function buildBaseRules(implementationMap) {
return new HelperSpec(
implementationMap[name].helper,
config,
implementationMap[name].helperMeta
implementationMap[name].helperMeta,
);
} else {
throw new Error(`Unable to resolve helper ${name}`);
Expand All @@ -47,12 +47,12 @@ function buildBaseRules(implementationMap) {
return new ComponentSpec(
implementationMap[name].componentPath,
config,
implementationMap[name].componentMeta
implementationMap[name].componentMeta,
);
} else {
throw new Error(`Unable to resolve component ${name}`);
}
}
},
),
];
}
Expand All @@ -76,14 +76,14 @@ function buildShorthandRules(implementationMap) {

function buildComponentRule(
name,
{ shorthandProperty, componentPath, componentMeta }
{ shorthandProperty, componentPath, componentMeta },
) {
return rule(
{ [`$${name}`]: subtree('shorthandValue'), ...rest('config') },
({ shorthandValue, config }) => {
let fullConfig = { [shorthandProperty]: shorthandValue, ...config };
return new ComponentSpec(componentPath, fullConfig, componentMeta);
}
},
);
}

Expand All @@ -93,6 +93,6 @@ function buildHelperRule(name, { shorthandProperty, helper, helperMeta }) {
({ shorthandValue, config }) => {
let fullConfig = { [shorthandProperty]: shorthandValue, ...config };
return new HelperSpec(helper, fullConfig, helperMeta);
}
},
);
}
Loading

0 comments on commit 66c5d30

Please sign in to comment.