diff --git a/src/arrays.js b/src/arrays.js index c5d41791..9e69d72f 100644 --- a/src/arrays.js +++ b/src/arrays.js @@ -1,12 +1,7 @@ import { createElement as $, Component as BaseComponent } from 'react' -import { - setItem, - insertItem, - insertItems, - lazyProperty, - EMPTY_ARRAY, -} from './tools' +import { lazyProperty } from './tools' +import { setItem, insertItem, insertItems, EMPTY_ARRAY } from './immutables' function onChangeItem(element) { return (itemValue, itemIndex, payload) => { diff --git a/src/children.js b/src/children.js new file mode 100644 index 00000000..dc57e8d3 --- /dev/null +++ b/src/children.js @@ -0,0 +1,60 @@ +import { createElement as $ } from 'react' +import { identity, mapValues, map } from 'lodash' +import { withPropsOnChange } from 'recompose' + +const DEFAULT_KEYS = ['value', 'name', 'onChange'] +const DEFAULT_CHILDREN_PROPS = ({ item }) => (value, index) => item(index) + +export function withChildren( + Component, + childProps = DEFAULT_CHILDREN_PROPS, + shouldUpdateOrKeys = DEFAULT_KEYS, + valueName = 'value', + destination = 'children', +) { + /* + Builds an array that maps every item from the `[valueName]` prop with the result of ` value => ({ value }))('ul') + */ + return withPropsOnChange(shouldUpdateOrKeys, (props) => ({ + [destination]: map( + props[valueName], + ((childProps) => (value, index) => + $(Component, { + key: index, + ...childProps(value, index), + }))(childProps(props)), + ), + })) +} + +export function withChild( + Component, + childProps = identity, + shouldUpdateOrKeys = DEFAULT_KEYS, + destination = 'children', +) { + /* + Builds an element from the provided `Component` with the props from `childProps(props)` and injects it as a `[destination]` prop. + The prop is only updated if `shouldUpdateOrKeys` returns `true` or if a prop whose name is listed in it changes. + */ + if (typeof Component === 'function') { + return withPropsOnChange(shouldUpdateOrKeys, (props) => ({ + [destination]: $(Component, childProps(props, null)), + })) + } + return withPropsOnChange(shouldUpdateOrKeys, (props) => ({ + [destination]: mapValues(Component, (Component, name) => + $(Component, childProps(props, name)), + ), + })) +} + +export const withElement = withChild diff --git a/src/contexts.js b/src/contexts.js new file mode 100644 index 00000000..6a68a870 --- /dev/null +++ b/src/contexts.js @@ -0,0 +1,23 @@ +import { createElement as $ } from 'react' + +export function withContext(provider, propName) { + /* + Injects a context `provider` that takes its value from `[propName]`. + */ + return (Component) => + function withContext(props) { + return $(provider, { value: props[propName] }, $(Component, props)) + } +} + +export function fromContext(consumer, propName) { + /* + Injects the value of the context `consumer` into `[propName]`. + */ + return (Component) => + function fromContext(props) { + return $(consumer, null, (value) => + $(Component, { ...props, [propName]: value }), + ) + } +} diff --git a/src/dom.js b/src/dom.js index 9a823cc9..0722e9a0 100644 --- a/src/dom.js +++ b/src/dom.js @@ -2,7 +2,9 @@ import { createElement as $, Component as BaseComponent } from 'react' import { memoize, get, pickBy } from 'lodash' import { compose, branch, withHandlers, mapProps } from 'recompose' -import { hasProp, syncedProp, EMPTY_OBJECT } from './tools' +import { syncedProp } from './properties' +import { hasProp } from './tools' +import { EMPTY_OBJECT } from './immutables' const PROP_NAMES = { accept: null, diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 00000000..77549909 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,5 @@ +export class AbortError extends Error { + /* + Error to be thrown in case the query call is aborted. + */ +} diff --git a/src/immutables.js b/src/immutables.js new file mode 100644 index 00000000..8f16b942 --- /dev/null +++ b/src/immutables.js @@ -0,0 +1,134 @@ +import { concat, get, indexOf, keys, omit, slice, uniq } from 'lodash' + +/* +Empty array to be used in immutable values. Using this instead of `[]` avoids having several instances of immutable empty arrays. +*/ +export const EMPTY_ARRAY = [] + +/* +Empty object to be used in immutable values. Using this instead of `{}` avoids having several instances of immutable empty objects. +*/ +export const EMPTY_OBJECT = {} + +export function insertItem( + array, + value, + index = array == null ? 0 : array.length, +) { + /* + Returns a new array with the `value` inserted into the `array` at the provided `index`, provided `value` is not `undefined`, in which case the `array` is returned untouched. + If the `index` is not provided, the `value` appended to the `array`. + If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. + */ + return array == null + ? value === undefined + ? EMPTY_ARRAY + : [value] + : value === undefined + ? array + : [...slice(array, 0, index), value, ...slice(array, index)] +} + +export function insertItems( + array, + value, + index = array == null ? 0 : array.length, +) { + /* + Returns a new array with the `value` array merged into the `array` at the provided `index`, provided `value` is not `nil`, in which case the `array` is returned untouched. + If the `index` is not provided, the `value` array is appended to the `array`. + If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. + */ + return array == null + ? value == null + ? EMPTY_ARRAY + : value + : value == null + ? array + : [...slice(array, 0, index), ...value, ...slice(array, index)] +} + +export function replaceItem(array, previousValue, value) { + /* + Returns a new array with the first occurence of the `previousValue` in `array` replaced by `value`. + Returns the same `array` if the `previousValue` is not found. + If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. + */ + return setItem(array, indexOf(array, previousValue), value) +} + +export function setItem(array, index, value) { + /* + Returns a new array with `array[index]` set to `value` if `array[index]` is strictly different from `value`. Otherwise, returns the provided `array`. + If `value` is `undefined`, ensures that the returned array does not contain the item found at `index`. + If `index` is greater than `array.length`, appends `value` to the `array`. + If `index` equals `-1` or is `undefined`, returns the `array` untouched. + If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. + */ + return index === -1 || index == null + ? array == null + ? EMPTY_ARRAY + : array + : array == null + ? value === undefined + ? EMPTY_ARRAY + : [value] + : value === undefined + ? index < array.length + ? [...slice(array, 0, index), ...slice(array, index + 1)] + : array + : array[index] === value + ? array + : [...slice(array, 0, index), value, ...slice(array, index + 1)] +} + +export function setProperty(object, key, value) { + /* + Returns a new object with `object[key]` set to `value` if `object[key]` is strictly different from `value`. Otherwise, returns the provided `object`. + If `value` is `undefined`, ensures that the returned object does not contain the `key`. + If `key` is `undefined`, returns the `object` untouched. + If `object` is `nil`, it is considered as an `EMPTY_OBJECT`. + */ + return key === undefined + ? object == null + ? EMPTY_OBJECT + : object + : object == null + ? value === undefined + ? EMPTY_OBJECT + : { [key]: value } + : value === undefined + ? key in object + ? omit(object, key) + : object + : object[key] === value + ? object + : { ...object, [key]: value } +} + +export function same( + a, + b, + properties = uniq(concat(keys(a), keys(b))), + deep = false, +) { + /* + Returns `true` if objects `a` and `b` have the same `properties`. + Unless provided, `properties` are the combined set of property names from `a` and `b`. + If `deep` is `true`, considers properties as paths (e.g., `p1.p2`). + */ + const { length } = properties + for (let i = 0; i < length; i++) { + const property = properties[i] + if (deep) { + if (get(a, property) !== get(b, property)) { + return false + } + } else { + if (a[property] !== b[property]) { + return false + } + } + } + return true +} diff --git a/src/index.js b/src/index.js index df85830d..d386195a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,16 @@ export * from './arrays' -export * from './objects' -export * from './values' export * from './booleans' -export * from './strings' -export * from './numbers' +export * from './children' +export * from './contexts' export * from './dates' - export * from './dom' -export * from './tools' +export * from './errors' +export * from './immutables' +export * from './numbers' +export * from './objects' +export * from './promises' +export * from './properties' export * from './queries' +export * from './strings' +export * from './tools' +export * from './values' diff --git a/src/numbers.js b/src/numbers.js index cce4be2b..d4401616 100644 --- a/src/numbers.js +++ b/src/numbers.js @@ -1,7 +1,8 @@ import { replace, trim } from 'lodash' import { branch, withProps } from 'recompose' -import { hasNotProp, escapeRegex, EMPTY_OBJECT } from './tools' +import { hasNotProp, escapeRegex } from './tools' +import { EMPTY_OBJECT } from './immutables' export const number = branch( /* diff --git a/src/objects.js b/src/objects.js index 1e70c16d..1b219d7e 100644 --- a/src/objects.js +++ b/src/objects.js @@ -2,7 +2,8 @@ import { createElement as $, Component as BaseComponent } from 'react' import { reduce, join, pick } from 'lodash' import { compose, branch, withHandlers } from 'recompose' -import { hasProp, setProperty, lazyProperty, EMPTY_OBJECT } from './tools' +import { setProperty, EMPTY_OBJECT } from './immutables' +import { hasProp, lazyProperty } from './tools' function onChangeProperty(element) { return (propertyValue, propertyName, payload) => { diff --git a/src/promises.js b/src/promises.js new file mode 100644 index 00000000..5f157b09 --- /dev/null +++ b/src/promises.js @@ -0,0 +1,16 @@ +import { AbortError } from './errors' + +/* +Returns a promise that resolves after at least `duration` milliseconds. +If a `signal` is provided, listens to it to reject +*/ +export const waitFor = (duration, signal) => + new Promise((resolve, reject) => { + const timer = window.setTimeout(resolve, duration) + if (signal) { + signal.addEventListener('abort', () => { + window.clearTimeout(timer) + reject(new AbortError('Aborted')) + }) + } + }) diff --git a/src/properties.js b/src/properties.js new file mode 100644 index 00000000..49fc71b9 --- /dev/null +++ b/src/properties.js @@ -0,0 +1,309 @@ +import { createElement as $, Component as BaseComponent } from 'react' +import { keys, omit, isString, upperFirst, debounce, indexOf } from 'lodash' +import { + mapProps, + lifecycle, + branch, + compose, + withPropsOnChange, + withHandlers, +} from 'recompose' + +import { EMPTY_OBJECT, same } from './immutables' +import { hasProps } from './tools' + +export function logProps(propNames, title) { + /* + Logs the provided `propNames` whenever they change. + The `title` defaults to the component name. + If `propNames` is `nil`, logs all props. + */ + return (Component) => + onPropsChange(propNames || undefined, (props) => { + /* eslint-disable no-console */ + console.group(title || Component.displayName || Component.name) + for (const name of propNames || keys(props)) { + console.log(name, props[name]) + } + console.groupEnd() + /* eslint-enable no-console */ + })(Component) +} + +export function omitProps(propNames) { + /* + Removes provided `propNames`. + */ + return mapProps((props) => omit(props, propNames)) +} + +export function onPropsChange(shouldHandleOrKeys, handler, callOnMount = true) { + /* + Similar to `withPropsOnChange`, except that the values of the `handler` are not merged into the props. + The `handler` is called when the component is first mounted if `callOnMount` is `true` (default value). + */ + const shouldHandle = + typeof shouldHandleOrKeys === 'function' + ? shouldHandleOrKeys + : (props, nextProps) => !same(props, nextProps, shouldHandleOrKeys) + return lifecycle({ + componentWillMount() { + if (callOnMount) { + handler(this.props, this.props) + } + }, + componentWillReceiveProps(nextProps) { + if (shouldHandle(this.props, nextProps)) { + handler(nextProps, this.props) + } + }, + }) +} + +export function delayedProp(options) { + /* + Delays `[name]` calls until after `[delayName]` milliseconds have elapsed since the last call. + Renames undelayed `[name]` as `onPushName`. + */ + const name = isString(options) ? options : options.name + const capitalizedName = upperFirst(name) + const { + delayName = `delay${capitalizedName}`, + onPushName = `onPush${capitalizedName}`, + } = name === options ? EMPTY_OBJECT : options + const propNames = [name, delayName] + return branch( + hasProps(propNames), + compose( + withPropsOnChange( + propNames, + ({ [name]: callable, [delayName]: delay }) => { + const debouncedCallable = debounce(callable, delay) + return { + [name]: debouncedCallable, + [onPushName]: callable, + } + }, + ), + ), + ) +} + +export function editableProp(options) { + /* + Enables a value prop of a given `name` to be locally editable. + The value can be updated with `[onChangeName]`. + */ + const name = isString(options) ? options : options.name + const { onChangeName = `onChange${upperFirst(name)}` } = + name === options ? EMPTY_OBJECT : options + return (Component) => + class editable extends BaseComponent { + constructor(props) { + super(props) + this.state = { + value: props[name], + } + this.onChange = (value) => this.setState({ value }) + } + render() { + return $(Component, { + ...this.props, + [name]: this.state.value, + [onChangeName]: this.onChange, + }) + } + } +} + +export function syncedProp(options) { + /* + Enables a prop with a given `name` to be locally editable while staying in sync with its parent value. + The prop can be updated with prop `[onChangeName](value, name, payload)`, which triggers the optional parent prop `[onChangeName]`. + Calling `[onPullName]()` sets the local value to the parent value. + The return value of the optional parent prop `[onPullName](newValue, previousValue)` is used on prop `[name]` changes or when calling `[onPullName]()`. + */ + const name = isString(options) ? options : options.name + const capitalizedName = upperFirst(name) + const { + onChangeName = `onChange${capitalizedName}`, + onPullName = `onPull${capitalizedName}`, + } = name === options ? EMPTY_OBJECT : options + return (Component) => + class synced extends BaseComponent { + constructor(props) { + super(props) + this.state = this.constructor.getDerivedStateFromProps( + props, + EMPTY_OBJECT, + ) + this.onChange = (value, name, payload) => { + if (value === this.state.value) { + return + } + const { [onChangeName]: onChange } = this.props + return this.setState( + { value }, + onChange == null ? undefined : () => onChange(value, name, payload), + ) + } + this.onPull = () => { + const { + props: { onPull }, + state: { value, originalValue }, + } = this + this.setState({ + value: + onPull == null ? originalValue : onPull(originalValue, value), + }) + } + } + static getDerivedStateFromProps(props, state) { + const { [name]: value, [onPullName]: onPull } = props + if (value === state.originalValue && state !== EMPTY_OBJECT) { + return null + } + return { + value: onPull == null ? value : onPull(value, state.value), + originalValue: value, + } + } + render() { + return $(Component, { + ...this.props, + [name]: this.state.value, + [onChangeName]: this.onChange, + [onPullName]: this.onPull, + }) + } + } +} + +export function cycledProp(options) { + /* + Injects prop `[onCycleName](payload)` that cycles the value of prop `[name]` through the values found in prop `[valuesName]` which default to `[false, true]`. + Calls `[onChangeName](value, name, payload)` with `name` taken from prop `[nameName]` or `name`. + */ + const name = isString(options) ? options : options.name + const capitalizedName = upperFirst(name) + const { + valuesName = `${name}Values`, + onCycleName = `onCycle${capitalizedName}`, + onChangeName = `onChange${capitalizedName}`, + nameName = `${name}Name`, + } = name === options ? EMPTY_OBJECT : options + return withHandlers({ + [onCycleName]: ({ + [name]: value, + [valuesName]: values = [false, true], + [onChangeName]: onChange, + [nameName]: valueName = name, + }) => (payload) => { + const index = indexOf(values, value) + 1 + onChange(values[index === values.length ? 0 : index], valueName, payload) + }, + }) +} + +export function promisedProp(name) { + /* + Replaces the promise at prop `[name]` with `{ done, error, value }`. + Before the promise resolves, `done` is `false`, and becomes `true` afterwards. + If an error occured in the promise, `error` is set to it. Otherwise, the `value` is set to the resolved value. + If the propmise at prop `[name]` changes, `done`, `error`, and `value` are reset and any previous promise is discarded. + */ + return (Component) => + class promised extends BaseComponent { + constructor(props) { + super(props) + this.state = this.constructor.getDerivedStateFromProps( + props, + EMPTY_OBJECT, + ) + this.mounted = false + } + + attachPromise(promise) { + if (promise == null) { + return + } + return Promise.resolve(promise).then( + (value) => { + if (!this.mounted || this.state.promise !== promise) { + return + } + this.setState({ result: { done: true, error: null, value } }) + }, + (error) => { + if (!this.mounted || this.state.promise !== promise) { + return + } + this.setState({ + result: { done: true, error, value: null }, + }) + }, + ) + } + + componentDidMount() { + this.mounted = true + this.attachPromise(this.state.promise) + } + + static getDerivedStateFromProps(props, state) { + const promise = props[name] + if (promise === state.promise && state !== EMPTY_OBJECT) { + return null + } + return { + promise, + result: { + done: false, + error: null, + value: null, + }, + } + } + + componentDidUpdate(prevProps, prevState) { + const { promise } = this.state + if (promise !== prevState.promise) { + this.attachPromise(promise) + } + } + + componentWillUnmount() { + this.mounted = false + } + + render() { + return $(Component, { + ...this.props, + [name]: this.state.result, + }) + } + } +} + +export function resilientProp(name) { + /* + Keeps the last non-`nil` value of prop `[name]`. + */ + return (Component) => + class resiliant extends BaseComponent { + constructor(props) { + super(props) + this.state = { [name]: props[name] } + } + static getDerivedStateFromProps(props, state) { + const value = props[name] + return value === state.value || value == null ? null : { value } + } + render() { + return $(Component, { + ...this.props, + [name]: this.state.value, + }) + } + } +} diff --git a/src/queries.js b/src/queries.js index d3ce507c..99742fa7 100644 --- a/src/queries.js +++ b/src/queries.js @@ -10,7 +10,9 @@ import { } from 'lodash' import { compose, withPropsOnChange } from 'recompose' -import { EMPTY_OBJECT, waitFor, setProperty, promisedProp } from './tools' +import { waitFor } from './promises' +import { EMPTY_OBJECT, setProperty } from './immutables' +import { promisedProp } from './properties' export class QueryError extends Error { /* @@ -70,8 +72,8 @@ export function split(condition, left, right = identity) { } export function cache({ - serialize = ({ value = EMPTY_OBJECT, method = 'get', type }) => - method === 'get' && value.id && `${type}/${value.id}`, + serialize = ({ value = EMPTY_OBJECT, method = 'get', type, refresh }) => + !refresh && method === 'get' && value.id && `${type}/${value.id}`, engine = new Map(), duration = 10 * 60 * 1000, } = EMPTY_OBJECT) { @@ -188,7 +190,7 @@ export function queryString(values) { Returns a key-sorted query string from provided `values` object. */ const result = new window.URLSearchParams() - for (let name in values) { + for (const name in values) { const value = values[name] if (value == null) { continue diff --git a/src/tests/immutables.js b/src/tests/immutables.js new file mode 100644 index 00000000..7d00d1e0 --- /dev/null +++ b/src/tests/immutables.js @@ -0,0 +1,123 @@ +import test from 'ava' + +import { + insertItem, + setItem, + setProperty, + EMPTY_ARRAY, + EMPTY_OBJECT, +} from '../immutables' + +function similar(assert, base, value, expected, message) { + assert.deepEqual(value, expected, message) + assert.not(value, base, 'does not mutate') +} + +test('insertItem', (assert) => { + assert.is(typeof insertItem, 'function') + const base = [0, 1, 2] + similar( + assert, + base, + insertItem(base, 3, 0), + [3, 0, 1, 2], + 'insertItems at head', + ) + similar( + assert, + base, + insertItem(base, 3, 1), + [0, 3, 1, 2], + 'insertItems inside', + ) + similar( + assert, + base, + insertItem(base, 3, 5), + [0, 1, 2, 3], + 'appends if out of bounds', + ) + similar(assert, base, insertItem(base, 3), [0, 1, 2, 3], 'appends by default') + similar(assert, null, insertItem(null, 3, 0), [3], 'creates array with value') + similar(assert, null, insertItem(null, 3), [3], 'creates array with value') +}) + +test('setItem', (assert) => { + assert.is(typeof setItem, 'function') + const base = [0, 1, 2] + similar( + assert, + base, + setItem(base, 0, 3), + [3, 1, 2], + 'replaces items at head', + ) + similar(assert, base, setItem(base, 1, 3), [0, 3, 2], 'replaces items inside') + similar( + assert, + base, + setItem(base, 2, 3), + [0, 1, 3], + 'replaces items at tail', + ) + similar( + assert, + base, + setItem(base, 5, 3), + [0, 1, 2, 3], + 'appends if out of bounds', + ) + similar(assert, null, setItem(null, 0, 3), [3], 'creates array') + assert.is(setItem(null, -1, 3), EMPTY_ARRAY, 'creates empty array') + assert.is(setItem(base, -1, 3), base, 'returns same array if not found') + assert.is(setItem(base, null, 3), base, 'returns same array if no index') + assert.is(setItem(), EMPTY_ARRAY, 'returns empty array if no arguments') + similar(assert, base, setItem(base, 0), [1, 2], 'removes item if undefined') +}) + +test('setProperty', (assert) => { + assert.is(typeof setProperty, 'function') + const base = { a: 1 } + similar( + assert, + base, + setProperty(base, 'a', 2), + { a: 2 }, + 'replaces existing key', + ) + similar( + assert, + base, + setProperty(base, 'b', 2), + { a: 1, b: 2 }, + 'adds new key', + ) + similar(assert, base, setProperty(base, 'a'), {}, 'removes key') + similar( + assert, + null, + setProperty(null, 'a', 1), + { a: 1 }, + 'creates object with key', + ) + similar( + assert, + null, + setProperty(null, 'a'), + EMPTY_OBJECT, + 'creates empty object', + ) + similar( + assert, + null, + setProperty(), + EMPTY_OBJECT, + 'creates empty object if no arguments', + ) + assert.is(setProperty(base, 'a', 1), base, 'returns same object if no change') + assert.is( + setProperty(base, 'b'), + base, + 'returns same object if key to remove is non-existent', + ) +}) diff --git a/src/tests/tools.js b/src/tests/tools.js index 7e953aea..571145ea 100644 --- a/src/tests/tools.js +++ b/src/tests/tools.js @@ -1,20 +1,6 @@ import test from 'ava' -import { - hasProp, - hasNotProp, - insertItem, - setItem, - setProperty, - isValidDate, - EMPTY_ARRAY, - EMPTY_OBJECT, -} from '../tools' - -function similar(assert, base, value, expected, message) { - assert.deepEqual(value, expected, message) - assert.not(value, base, 'does not mutate') -} +import { hasProp, hasNotProp, isValidDate } from '../tools' test('hasProp', (assert) => { assert.is(typeof hasProp, 'function') @@ -35,115 +21,6 @@ test('hasNotProp', (assert) => { assert.true(hasNotProp('onChange')({}), 'detects missing onChange') }) -test('insertItem', (assert) => { - assert.is(typeof insertItem, 'function') - const base = [0, 1, 2] - similar( - assert, - base, - insertItem(base, 3, 0), - [3, 0, 1, 2], - 'insertItems at head', - ) - similar( - assert, - base, - insertItem(base, 3, 1), - [0, 3, 1, 2], - 'insertItems inside', - ) - similar( - assert, - base, - insertItem(base, 3, 5), - [0, 1, 2, 3], - 'appends if out of bounds', - ) - similar(assert, base, insertItem(base, 3), [0, 1, 2, 3], 'appends by default') - similar(assert, null, insertItem(null, 3, 0), [3], 'creates array with value') - similar(assert, null, insertItem(null, 3), [3], 'creates array with value') -}) - -test('setItem', (assert) => { - assert.is(typeof setItem, 'function') - const base = [0, 1, 2] - similar( - assert, - base, - setItem(base, 0, 3), - [3, 1, 2], - 'replaces items at head', - ) - similar(assert, base, setItem(base, 1, 3), [0, 3, 2], 'replaces items inside') - similar( - assert, - base, - setItem(base, 2, 3), - [0, 1, 3], - 'replaces items at tail', - ) - similar( - assert, - base, - setItem(base, 5, 3), - [0, 1, 2, 3], - 'appends if out of bounds', - ) - similar(assert, null, setItem(null, 0, 3), [3], 'creates array') - assert.is(setItem(null, -1, 3), EMPTY_ARRAY, 'creates empty array') - assert.is(setItem(base, -1, 3), base, 'returns same array if not found') - assert.is(setItem(base, null, 3), base, 'returns same array if no index') - assert.is(setItem(), EMPTY_ARRAY, 'returns empty array if no arguments') - similar(assert, base, setItem(base, 0), [1, 2], 'removes item if undefined') -}) - -test('setProperty', (assert) => { - assert.is(typeof setProperty, 'function') - const base = { a: 1 } - similar( - assert, - base, - setProperty(base, 'a', 2), - { a: 2 }, - 'replaces existing key', - ) - similar( - assert, - base, - setProperty(base, 'b', 2), - { a: 1, b: 2 }, - 'adds new key', - ) - similar(assert, base, setProperty(base, 'a'), {}, 'removes key') - similar( - assert, - null, - setProperty(null, 'a', 1), - { a: 1 }, - 'creates object with key', - ) - similar( - assert, - null, - setProperty(null, 'a'), - EMPTY_OBJECT, - 'creates empty object', - ) - similar( - assert, - null, - setProperty(), - EMPTY_OBJECT, - 'creates empty object if no arguments', - ) - assert.is(setProperty(base, 'a', 1), base, 'returns same object if no change') - assert.is( - setProperty(base, 'b'), - base, - 'returns same object if key to remove is non-existent', - ) -}) - test('isValidDate', (assert) => { assert.is(typeof isValidDate, 'function') assert.true(isValidDate(new Date()), 'detects valid date') diff --git a/src/tools.js b/src/tools.js index b47d392b..a0440e10 100644 --- a/src/tools.js +++ b/src/tools.js @@ -1,39 +1,4 @@ -import { createElement as $, Component as BaseComponent } from 'react' -import { - concat, - debounce, - every, - get, - indexOf, - isString, - keys, - memoize, - omit, - slice, - uniq, - upperFirst, - map, - identity, - mapValues, -} from 'lodash' -import { - branch, - compose, - lifecycle, - mapProps, - withHandlers, - withPropsOnChange, -} from 'recompose' - -/* -Empty array to be used in immutable values. Using this instead of `[]` avoids having several instances of immutable empty arrays. -*/ -export const EMPTY_ARRAY = [] - -/* -Empty object to be used in immutable values. Using this instead of `{}` avoids having several instances of immutable empty objects. -*/ -export const EMPTY_OBJECT = {} +import { every, memoize } from 'lodash' /* Returns a function that checks if `props[name]` is not `nil`. @@ -45,156 +10,12 @@ Returns a function that checks if `props[name]` is `nil`. */ export const hasNotProp = memoize((name) => ({ [name]: prop }) => prop == null) -export class AbortError extends Error { - /* - Error to be thrown in case the query call is aborted. - */ -} - -/* -Returns a promise that resolves after at least `duration` milliseconds. -If a `signal` is provided, listens to it to reject -*/ -export const waitFor = (duration, signal) => - new Promise((resolve, reject) => { - const timer = window.setTimeout(resolve, duration) - if (signal) { - signal.addEventListener('abort', () => { - window.clearTimeout(timer) - reject(new AbortError('Aborted')) - }) - } - }) - /* Returns a function that checks if every prop `name` in `names` is not `nil`. */ export const hasProps = (names) => (props) => every(names, (name) => props[name] != null) -export function insertItem( - array, - value, - index = array == null ? 0 : array.length, -) { - /* - Returns a new array with the `value` inserted into the `array` at the provided `index`, provided `value` is not `undefined`, in which case the `array` is returned untouched. - If the `index` is not provided, the `value` appended to the `array`. - If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. - */ - return array == null - ? value === undefined - ? EMPTY_ARRAY - : [value] - : value === undefined - ? array - : [...slice(array, 0, index), value, ...slice(array, index)] -} - -export function insertItems( - array, - value, - index = array == null ? 0 : array.length, -) { - /* - Returns a new array with the `value` array merged into the `array` at the provided `index`, provided `value` is not `nil`, in which case the `array` is returned untouched. - If the `index` is not provided, the `value` array is appended to the `array`. - If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. - */ - return array == null - ? value == null - ? EMPTY_ARRAY - : value - : value == null - ? array - : [...slice(array, 0, index), ...value, ...slice(array, index)] -} - -export function replaceItem(array, previousValue, value) { - /* - Returns a new array with the first occurence of the `previousValue` in `array` replaced by `value`. - Returns the same `array` if the `previousValue` is not found. - If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. - */ - return setItem(array, indexOf(array, previousValue), value) -} - -export function setItem(array, index, value) { - /* - Returns a new array with `array[index]` set to `value` if `array[index]` is strictly different from `value`. Otherwise, returns the provided `array`. - If `value` is `undefined`, ensures that the returned array does not contain the item found at `index`. - If `index` is greater than `array.length`, appends `value` to the `array`. - If `index` equals `-1` or is `undefined`, returns the `array` untouched. - If the `array` is `nil`, it is considered as an `EMPTY_ARRAY`. - */ - return index === -1 || index == null - ? array == null - ? EMPTY_ARRAY - : array - : array == null - ? value === undefined - ? EMPTY_ARRAY - : [value] - : value === undefined - ? index < array.length - ? [...slice(array, 0, index), ...slice(array, index + 1)] - : array - : array[index] === value - ? array - : [...slice(array, 0, index), value, ...slice(array, index + 1)] -} - -export function setProperty(object, key, value) { - /* - Returns a new object with `object[key]` set to `value` if `object[key]` is strictly different from `value`. Otherwise, returns the provided `object`. - If `value` is `undefined`, ensures that the returned object does not contain the `key`. - If `key` is `undefined`, returns the `object` untouched. - If `object` is `nil`, it is considered as an `EMPTY_OBJECT`. - */ - return key === undefined - ? object == null - ? EMPTY_OBJECT - : object - : object == null - ? value === undefined - ? EMPTY_OBJECT - : { [key]: value } - : value === undefined - ? key in object - ? omit(object, key) - : object - : object[key] === value - ? object - : { ...object, [key]: value } -} - -export function same( - a, - b, - properties = uniq(concat(keys(a), keys(b))), - deep = false, -) { - /* - Returns `true` if objects `a` and `b` have the same `properties`. - Unless provided, `properties` are the combined set of property names from `a` and `b`. - If `deep` is `true`, considers properties as paths (e.g., `p1.p2`). - */ - const { length } = properties - for (let i = 0; i < length; i++) { - const property = properties[i] - if (deep) { - if (get(a, property) !== get(b, property)) { - return false - } - } else { - if (a[property] !== b[property]) { - return false - } - } - } - return true -} - const REGEX_CHARS_PATTERN = /[.?*+^$[\]\\(){}|-]/g export function escapeRegex(pattern) { return (pattern + '').replace(REGEX_CHARS_PATTERN, '\\$&') @@ -213,256 +34,6 @@ export function called(object, property) { return object } -export function logProps(propNames, title) { - /* - Logs the provided `propNames` whenever they change. - The `title` defaults to the component name. - If `propNames` is `nil`, logs all props. - */ - return (Component) => - onPropsChange(propNames || undefined, (props) => { - /* eslint-disable no-console */ - console.group(title || Component.displayName || Component.name) - for (let name of propNames || keys(props)) { - console.log(name, props[name]) - } - console.groupEnd() - /* eslint-enable no-console */ - })(Component) -} - -export function omitProps(propNames) { - /* - Removes provided `propNames`. - */ - return mapProps((props) => omit(props, propNames)) -} - -export function onPropsChange(shouldHandleOrKeys, handler, callOnMount = true) { - /* - Similar to `withPropsOnChange`, except that the values of the `handler` are not merged into the props. - The `handler` is called when the component is first mounted if `callOnMount` is `true` (default value). - */ - const shouldHandle = - typeof shouldHandleOrKeys === 'function' - ? shouldHandleOrKeys - : (props, nextProps) => !same(props, nextProps, shouldHandleOrKeys) - return lifecycle({ - componentWillMount() { - if (callOnMount) { - handler(this.props, this.props) - } - }, - componentWillReceiveProps(nextProps) { - if (shouldHandle(this.props, nextProps)) { - handler(nextProps, this.props) - } - }, - }) -} - -export function delayedProp(options) { - /* - Delays `[name]` calls until after `[delayName]` milliseconds have elapsed since the last call. - Renames undelayed `[name]` as `onPushName`. - */ - const name = isString(options) ? options : options.name - const capitalizedName = upperFirst(name) - const { - delayName = `delay${capitalizedName}`, - onPushName = `onPush${capitalizedName}`, - } = name === options ? EMPTY_OBJECT : options - const propNames = [name, delayName] - return branch( - hasProps(propNames), - compose( - withPropsOnChange( - propNames, - ({ [name]: callable, [delayName]: delay }) => { - const debouncedCallable = debounce(callable, delay) - return { - [name]: debouncedCallable, - [onPushName]: callable, - } - }, - ), - ), - ) -} - -export function editableProp(options) { - /* - Enables a value prop of a given `name` to be locally editable. - The value can be updated with `[onChangeName]`. - */ - const name = isString(options) ? options : options.name - const { onChangeName = `onChange${upperFirst(name)}` } = - name === options ? EMPTY_OBJECT : options - return (Component) => - class editable extends BaseComponent { - constructor(props) { - super(props) - this.state = { - value: props[name], - } - this.onChange = (value) => this.setState({ value }) - } - render() { - return $(Component, { - ...this.props, - [name]: this.state.value, - [onChangeName]: this.onChange, - }) - } - } -} - -export function syncedProp(options) { - /* - Enables a prop with a given `name` to be locally editable while staying in sync with its parent value. - The prop can be updated with prop `[onChangeName](value, name, payload)`, which triggers the optional parent prop `[onChangeName]`. - Calling `[onPullName]()` sets the local value to the parent value. - The return value of the optional parent prop `[onPullName](newValue, previousValue)` is used on prop `[name]` changes or when calling `[onPullName]()`. - */ - const name = isString(options) ? options : options.name - const capitalizedName = upperFirst(name) - const { - onChangeName = `onChange${capitalizedName}`, - onPullName = `onPull${capitalizedName}`, - } = name === options ? EMPTY_OBJECT : options - return (Component) => - class synced extends BaseComponent { - constructor(props) { - super(props) - this.state = this.constructor.getDerivedStateFromProps( - props, - EMPTY_OBJECT, - ) - this.onChange = (value, name, payload) => { - if (value === this.state.value) { - return - } - const { [onChangeName]: onChange } = this.props - return this.setState( - { value }, - onChange == null ? undefined : () => onChange(value, name, payload), - ) - } - this.onPull = () => { - const { - props: { onPull }, - state: { value, originalValue }, - } = this - this.setState({ - value: - onPull == null ? originalValue : onPull(originalValue, value), - }) - } - } - static getDerivedStateFromProps(props, state) { - const { [name]: value, [onPullName]: onPull } = props - if (value === state.originalValue && state !== EMPTY_OBJECT) { - return null - } - return { - value: onPull == null ? value : onPull(value, state.value), - originalValue: value, - } - } - render() { - return $(Component, { - ...this.props, - [name]: this.state.value, - [onChangeName]: this.onChange, - [onPullName]: this.onPull, - }) - } - } -} - -export function cycledProp(options) { - /* - Injects prop `[onCycleName](payload)` that cycles the value of prop `[name]` through the values found in prop `[valuesName]` which default to `[false, true]`. - Calls `[onChangeName](value, name, payload)` with `name` taken from prop `[nameName]` or `name`. - */ - const name = isString(options) ? options : options.name - const capitalizedName = upperFirst(name) - const { - valuesName = `${name}Values`, - onCycleName = `onCycle${capitalizedName}`, - onChangeName = `onChange${capitalizedName}`, - nameName = `${name}Name`, - } = name === options ? EMPTY_OBJECT : options - return withHandlers({ - [onCycleName]: ({ - [name]: value, - [valuesName]: values = [false, true], - [onChangeName]: onChange, - [nameName]: valueName = name, - }) => (payload) => { - const index = indexOf(values, value) + 1 - onChange(values[index === values.length ? 0 : index], valueName, payload) - }, - }) -} - -const DEFAULT_KEYS = ['value', 'name', 'onChange'] -const DEFAULT_CHILDREN_PROPS = ({ item }) => (value, index) => item(index) - -export function withChildren( - Component, - childProps = DEFAULT_CHILDREN_PROPS, - shouldUpdateOrKeys = DEFAULT_KEYS, - valueName = 'value', - destination = 'children', -) { - /* - Builds an array that maps every item from the `[valueName]` prop with the result of ` value => ({ value }))('ul') - */ - return withPropsOnChange(shouldUpdateOrKeys, (props) => ({ - [destination]: map( - props[valueName], - ((childProps) => (value, index) => - $(Component, { - key: index, - ...childProps(value, index), - }))(childProps(props)), - ), - })) -} - -export function withChild( - Component, - childProps = identity, - shouldUpdateOrKeys = DEFAULT_KEYS, - destination = 'children', -) { - /* - Builds an element from the provided `Component` with the props from `childProps(props)` and injects it as a `[destination]` prop. - The prop is only updated if `shouldUpdateOrKeys` returns `true` or if a prop whose name is listed in it changes. - */ - if (typeof Component === 'function') { - return withPropsOnChange(shouldUpdateOrKeys, (props) => ({ - [destination]: $(Component, childProps(props, null)), - })) - } - return withPropsOnChange(shouldUpdateOrKeys, (props) => ({ - [destination]: mapValues(Component, (Component, name) => - $(Component, childProps(props, name)), - ), - })) -} - -export const withElement = withChild - export function lazyProperty(object, propertyName, valueBuilder) { /* Returns `object[propertyName]` if not `nil`, otherwise sets the result of `valueBuilder(object)` to it and returns it. @@ -474,128 +45,3 @@ export function lazyProperty(object, propertyName, valueBuilder) { } return (object[propertyName] = valueBuilder(object)) } - -export function promisedProp(name) { - /* - Replaces the promise at prop `[name]` with `{ done, error, value }`. - Before the promise resolves, `done` is `false`, and becomes `true` afterwards. - If an error occured in the promise, `error` is set to it. Otherwise, the `value` is set to the resolved value. - If the propmise at prop `[name]` changes, `done`, `error`, and `value` are reset and any previous promise is discarded. - */ - return (Component) => - class promised extends BaseComponent { - constructor(props) { - super(props) - this.state = this.constructor.getDerivedStateFromProps( - props, - EMPTY_OBJECT, - ) - this.mounted = false - } - - attachPromise(promise) { - if (promise == null) { - return - } - return Promise.resolve(promise).then( - (value) => { - if (!this.mounted || this.state.promise !== promise) { - return - } - this.setState({ result: { done: true, error: null, value } }) - }, - (error) => { - if (!this.mounted || this.state.promise !== promise) { - return - } - this.setState({ - result: { done: true, error, value: null }, - }) - }, - ) - } - - componentDidMount() { - this.mounted = true - this.attachPromise(this.state.promise) - } - - static getDerivedStateFromProps(props, state) { - const promise = props[name] - if (promise === state.promise && state !== EMPTY_OBJECT) { - return null - } - return { - promise, - result: { - done: false, - error: null, - value: null, - }, - } - } - - componentDidUpdate(prevProps, prevState) { - const { promise } = this.state - if (promise !== prevState.promise) { - this.attachPromise(promise) - } - } - - componentWillUnmount() { - this.mounted = false - } - - render() { - return $(Component, { - ...this.props, - [name]: this.state.result, - }) - } - } -} - -export function resilientProp(name) { - /* - Keeps the last non-`nil` value of prop `[name]`. - */ - return (Component) => - class resiliant extends BaseComponent { - constructor(props) { - super(props) - this.state = { [name]: props[name] } - } - static getDerivedStateFromProps(props, state) { - const value = props[name] - return value === state.value || value == null ? null : { value } - } - render() { - return $(Component, { - ...this.props, - [name]: this.state.value, - }) - } - } -} - -export function withContext(provider, propName) { - /* - Injects a context `provider` that takes its value from `[propName]`. - */ - return (Component) => - function withContext(props) { - return $(provider, { value: props[propName] }, $(Component, props)) - } -} - -export function fromContext(consumer, propName) { - /* - Injects the value of the context `consumer` into `[propName]`. - */ - return (Component) => - function fromContext(props) { - return $(consumer, null, (value) => - $(Component, { ...props, [propName]: value }), - ) - } -} diff --git a/src/values.js b/src/values.js index c0d5ff95..fba32aca 100644 --- a/src/values.js +++ b/src/values.js @@ -2,17 +2,16 @@ import { createElement as $, Component as BaseComponent } from 'react' import { compose, branch, withHandlers, withPropsOnChange } from 'recompose' import { memoize, get } from 'lodash' +import { hasProp, hasProps } from './tools' import { - hasProp, - hasProps, delayedProp, syncedProp, editableProp, cycledProp, promisedProp, resilientProp, - EMPTY_OBJECT, -} from './tools' +} from './properties' +import { EMPTY_OBJECT } from './immutables' export const defaultValue = (Component) => /*