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' } }); + }); + + + }); + }); });