diff --git a/README.md b/README.md
index 247051b..9df08fc 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@ Preconf is just 400 bytes and works well with [preact-context-provider](https://
## Usage
+Install: `npm i preconf`
+
```js
import preconf from 'preconf';
import Provider from 'preact-context-provider';
@@ -44,6 +46,89 @@ render(
// hello, Stan
```
+**Collisions**
+
+In the event that a configuration field is present as a mergeable object in both `context.config` and the default configuration object provided to `preconf`, the resulting prop passed to the enhanced component will be a merged object, with the configuration value from `context` overriding the value from the default configuration for keys that cannot be merged.
+
+Default behavior:
+
+```js
+const defaults = { name: { first: 'Bob', } };
+let configure = preconf(null, defaults);
+
+let FooWithDefaults = configure('name')(Foo)
+
+render(
+
+
+
+)
+// Foo receives prop `name` as merged object: { first: 'Bob', last: 'Jones'}
+```
+
+To prevent the default behavior and prevent deep-merging mergeable objects, `mergeProps` in the `options` parameter can be set to `false`.
+
+_Note: precedence will be given to the object in `context` unless `yieldToContext` in `options` is set to `false`._
+
+Override with value from `context.config`:
+
+```js
+const defaults = { name: { first: 'Bob', } };
+// Additional options parameter passed with mergeProps set to false
+let configure = preconf(null, defaults, { mergeProps: false });
+
+let FooWithDefaults = configure('name')(Foo)
+
+render(
+
+
+
+)
+// Foo receives prop `name` as unmerged object: { last: 'Jones'}
+```
+
+To prevent the default behavior and allow values in the default mergeable object to take precedence over values from the mergeable object from `context.config`, `yieldToContext` in the `options` parameter can be set to `false`.
+
+Override value from `context.config` with value from default:
+
+```js
+const defaults = { name: { first: 'Bob', } };
+// Additional options parameter passed with yieldToContext set to false
+let configure = preconf(null, defaults, { mergeProps: false, yieldToContext: false });
+
+let FooWithDefaults = configure('name')(Foo)
+
+render(
+
+
+
+)
+// Foo receives prop `name` as unmerged object: { first: 'Bob'}
+```
+
+To prevent the default behavior and override only specific top-level keys, `mergeProps` in the `options` parameter can be set to an `Array` of key names, where only the keys present in the `mergeProps` `Array` will be merged.
+
+_Note: Selective key-merging only applies to top-level keys; nested keys of the same name are merged._
+
+_Note: Can be used in combination with `yieldToContext` to determine precedence in merging select top-level keys._
+
+Override only specific top-level keys:
+
+```js
+const defaults = { name: { first: 'Bob', }, location: { city: 'Hamburg' } };
+// Additional options parameter passed with mergeProps set to an Array of keys to be merged
+let configure = preconf(null, defaults, { mergeProps: ['location'] });
+
+let FooWithDefaults = configure('name, location')(Foo)
+
+render(
+
+
+
+)
+// Foo receives prop `name` as unmerged object: { last: 'Jones'}, and prop `location` as merged object: { country: 'Germany', city: 'Hamburg' }
+```
+
## API
@@ -54,8 +139,11 @@ Creates a higher order component that provides values from configuration as prop
**Parameters**
-- `namespace` **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)?** If provided, exposes `defaults` under a `namespace`
-- `defaults` **[Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)?** An object containing default configuration values
+- `namespace` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** If provided, exposes `defaults` under a `namespace`
+- `defaults` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)?** An object containing default configuration values
+- `options` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)?** An object containing options for resolving configuration values (optional, default `{}`)
+ - `options.mergeProps` **([Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array))?** A boolean indicating whether props should be merged, or an array indicating which keys in props should be merged (optional, default `true`)
+ - `options.yieldToContext` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)?** A boolean where false indicates that the default values should override those from context, else values in context take precedence (optional, default `true`)
**Examples**
@@ -80,7 +168,16 @@ export default configure({
);
```
-Returns **[Function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function)** [configure()](#configure)
+```javascript
+// context.config = { location: { city: 'Hamburg' }}
+
+let configure = preconf(null, { location: { country: 'Germany' } });
+export default configure('location')( (props) =>
+ Location: {`${props.location.city}, ${props.location.country}`}
+);
+```
+
+Returns **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** [configure()](#configure)
#### configure
@@ -88,6 +185,6 @@ Creates a Higher Order Component that provides configuration as props.
**Parameters**
-- `keys` **([Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object) \| [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)<[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)>)** An object where the keys are prop names to pass down and values are dot-notated keypaths corresponding to values in configuration. If a string or array, prop names are inferred from configuration keys.
+- `keys` **([Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>)** An object where the keys are prop names to pass down and values are dot-notated keypaths corresponding to values in configuration. If a string or array, prop names are inferred from configuration keys.
-Returns **[Function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function)** configureComponent(Component) -> Component
+Returns **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** configureComponent(Component) -> Component
diff --git a/package.json b/package.json
index 6eba6ec..7eaa7b8 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
"preact": "*"
},
"dependencies": {
+ "deepmerge": "^1.5.0",
"dlv": "^1.1.0"
}
}
diff --git a/rollup.config.js b/rollup.config.js
index 7eee98c..2c0a43e 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -4,11 +4,13 @@ export default {
useStrict: false,
external: [
'preact',
- 'dlv'
+ 'dlv',
+ 'deepmerge'
],
globals: {
preact: 'preact',
- dlv: 'dlv'
+ dlv: 'dlv',
+ deepmerge: 'deepmerge'
},
plugins: [
buble({
diff --git a/src/index.js b/src/index.js
index 3684bc6..4a2c413 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,9 +1,13 @@
import { h } from 'preact';
import delve from 'dlv';
+import merge from 'deepmerge';
/** Creates a higher order component that provides values from configuration as props.
- * @param {String} [namespace] If provided, exposes `defaults` under a `namespace`
- * @param {Object} [defaults] An object containing default configuration values
+ * @param {String} [namespace] If provided, exposes `defaults` under a `namespace`
+ * @param {Object} [defaults] An object containing default configuration values
+ * @param {Object} [options] An object containing options for resolving configuration values
+ * @param {Boolean|Array} [options.mergeProps] A boolean indicating whether props should be merged, or an array indicating which keys in props should be merged
+ * @param {Boolean} [options.yieldToContext] A boolean where false indicates that the default values should override those from context, else values in context take precedence
* @returns {Function} [configure()](#configure)
*
* @example
@@ -23,8 +27,17 @@ import delve from 'dlv';
* })( ({ url }) =>
*
* );
+ *
+ * @example
+ * // context.config = { location: { city: 'Hamburg' }}
+ *
+ * let configure = preconf(null, { location: { country: 'Germany' } });
+ * export default configure('location')( (props) =>
+ * Location: {`${props.location.city}, ${props.location.country}`}
+ * );
*/
-export default function preconf(namespace, defaults) {
+export default function preconf(namespace, defaults, { mergeProps=true, yieldToContext=true }={}) {
+
if (namespace) defaults = { [namespace]: defaults };
/** Creates a Higher Order Component that provides configuration as props.
@@ -43,8 +56,18 @@ export default function preconf(namespace, defaults) {
for (let key in keys) {
let path = keys[key];
if (isArray) key = path.split('.').pop();
- if (typeof props[key]==='undefined' || props[key]===null) {
- props[key] = delve(context, 'config.'+path, delve(defaults, path));
+
+ let inheritedVal = delve(context, 'config.'+path);
+ let defaultVal = delve(defaults, path);
+
+ let deepMerge = Array.isArray(mergeProps) ? ~mergeProps.indexOf(path) : mergeProps;
+
+ if (deepMerge && inheritedVal && defaultVal && typeof inheritedVal === 'object' && typeof defaultVal === 'object') {
+ props[key] = yieldToContext ? merge(defaultVal, inheritedVal) : merge(inheritedVal, defaultVal);
+ }
+
+ else if (typeof props[key]==='undefined' || props[key]===null) {
+ props[key] = yieldToContext ? inheritedVal || defaultVal : defaultVal || inheritedVal;
}
}
return h(Child, props);
diff --git a/test/index.js b/test/index.js
index 84425d4..a81a051 100644
--- a/test/index.js
+++ b/test/index.js
@@ -118,5 +118,220 @@ describe('preconf', () => {
});
});
});
+
+ describe('opts.mergeProps', () => {
+ let defaults = {
+ a: 'b',
+ c: { d: 'e', f: { g: 'h' } }
+ };
+
+ let Child = spy( () => null );
+
+ function test(config, selector, opts) {
+ Child.reset();
+ let Wrapped = preconf(null, defaults, opts)(selector)(Child);
+ rndr();
+ }
+
+ it('should pass defaults', () => {
+ test(undefined, ['a', 'c']);
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith(defaults);
+ });
+
+ it('should pass provided config values', () => {
+ let config = { c: 'override', e: 'f' };
+
+ test(config, ['a', 'c', 'e']);
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'b', c: 'override', e: 'f' });
+
+ });
+
+ it('should deep merge config values by default', () => {
+ let config = { c: { f: { g: 'override' } }, e: 'f' };
+
+ test(config, ['a', 'c', 'e']);
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'b', c: { d: 'e', f: { g: 'override' } }, e: 'f' });
+
+ config = { c: { d: 'override' }, e: 'f' };
+
+ test(config, ['a', 'c', 'e']);
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'b', c: { d: 'override', f: { g: 'h' } }, e: 'f' });
+
+ });
+
+ it('should not deep merge config values when merge props option set to false', () => {
+ let config = { c: { f: { g: 'override' } }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ ...defaults, ...config });
+
+ config = { c: { d: 'override' }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ ...defaults, ...config });
+
+ });
+
+ it('should merge only select keys when merge props opt is provided Array of keys to merge', () => {
+ let config = { a: 'override', c: { f: { g: 'override' } }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: ['c'] });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'override', c: { d: 'e', f: { g: 'override' } }, e: 'f' });
+
+ config = { c: { d: 'override' }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: [] });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ ...defaults, ...config });
+
+ });
+
+ });
+
+ describe('opts.yieldToContext', () => {
+ let defaults = {
+ a: 'b',
+ c: { d: 'e', f: { g: 'h' } }
+ };
+
+ let Child = spy( () => null );
+
+ function test(config, selector, opts) {
+ Child.reset();
+ let Wrapped = preconf(null, defaults, opts)(selector)(Child);
+ rndr();
+ }
+
+ it('should override config from context when default config given precedence', () => {
+ let config = { c: { f: { g: 'override' } }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { yieldToContext: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'b', c: { d: 'e', f: { g: 'h' } }, e: 'f' });
+
+ config = { a: 'override', c: { d: 'override' }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { yieldToContext: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'b', c: { d: 'e', f: { g: 'h' } }, e: 'f' });
+
+ });
+
+ it('should not override default configs when merge props option set to false', () => {
+ let config = { c: { f: { g: 'override' } }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: false, yieldToContext: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ ...config, ...defaults });
+
+ config = { c: { d: 'override' }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: false, yieldToContext: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ ...config, ...defaults });
+
+ });
+
+ it('should merge only select keys when merge props opt is provided Array of keys to merge', () => {
+ let config = { a: 'override', c: { f: { g: 'override' } }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: ['c'], yieldToContext: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ a: 'b', c: { d: 'e', f: { g: 'h' } }, e: 'f' });
+
+ config = { c: { d: 'override' }, e: 'f' };
+
+ test(config, ['a', 'c', 'e'], { mergeProps: [], yieldToContext: false });
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ ...config, ...defaults });
+
+ });
+
+ });
+
+ describe('namespacing', () => {
+
+ it('should pass the correct props when no namespace is provided - prop name specified as string', () => {
+ let Child = spy( () => null );
+ let configure = preconf(null, { headquarters: { city: 'Hamburg' } });
+ let Wrapped = configure('headquarters')(Child);
+
+ let config = { headquarters: { country: 'Germany' } };
+ rndr();
+
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ headquarters: { city: 'Hamburg', country: 'Germany' } });
+ });
+
+ it('should pass the correct props when no namespace is provided - prop name specified as object', () => {
+ let Child = spy( () => null );
+ let configure = preconf(null, { headquarters: { city: 'Hamburg' } });
+ let Wrapped = configure({ location: 'headquarters' })(Child);
+
+ let config = { headquarters: { country: 'Germany' } };
+ rndr();
+
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ location: { city: 'Hamburg', country: 'Germany' } });
+ });
+
+ it('should pass the correct props for provided namespace', () => {
+ let Child = spy( () => null );
+ let configure = preconf('hq', { headquarters: { city: 'Hamburg' } });
+ let Wrapped = configure({ location: 'hq.headquarters' })(Child);
+
+ let config = { headquarters: { country: 'Germany' } };
+ rndr();
+
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ location: { city: 'Hamburg' } });
+ });
+
+ it('should pass the correct props for conflicting namespace - opts === default', () => {
+ let Child = spy( () => null );
+ let configure = preconf('hq', { headquarters: { city: 'Hamburg' } });
+ let Wrapped = configure({ location: 'hq.headquarters' })(Child);
+
+ let config = { hq: { headquarters: { country: 'Germany' } } };
+ rndr();
+
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ location: { city: 'Hamburg', country: 'Germany' } });
+ });
+
+ it('should pass the correct props for conflicting namespace - opts === { mergeProps: false }', () => {
+ let Child = spy( () => null );
+ let configure = preconf('hq', { headquarters: { city: 'Hamburg' } }, { mergeProps: false });
+ let Wrapped = configure({ location: 'hq.headquarters' })(Child);
+
+ let config = { hq: { headquarters: { country: 'Germany' } } };
+ rndr();
+
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ location: { country: 'Germany' } });
+ });
+
+ it('should pass the correct props for conflicting namespace - opts === { mergeProps: false, yieldToContext: false }', () => {
+ let Child = spy( () => null );
+ let configure = preconf('hq', { headquarters: { city: 'Hamburg' } }, { mergeProps: false, yieldToContext: false });
+ let Wrapped = configure({ location: 'hq.headquarters' })(Child);
+
+ let config = { hq: { headquarters: { country: 'Germany' } } };
+ rndr();
+
+ delete Child.args[0][0].children;
+ expect(Child).to.have.been.calledWith({ location: { city: 'Hamburg' } });
+ });
+
+
+ });
+
});
});