├── src ├── __tests__ │ ├── .eslintrc.js │ ├── connect-test.js │ └── compose-test.js ├── getDisplayName.js ├── config.js ├── connect.js └── index.js ├── .babelrc ├── circle.yml ├── .eslintrc.js ├── lib ├── getDisplayName.js ├── config.js ├── __tests__ │ ├── connect-test.js │ └── compose-test.js ├── connect.js └── index.js ├── .gitignore ├── LICENSE ├── package.json └── README.md /src/__tests__/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "react", "stage-0", "es2015" ], 3 | "plugins": [ "lodash" ], 4 | } 5 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8.0.0 4 | dependencies: 5 | pre: 6 | - npm install -g npm 7 | test: 8 | post: 9 | - npm run semantic-release || true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb', 3 | parser: 'babel-eslint', 4 | plugins: [ 5 | 'react', 6 | ], 7 | rules: { 8 | 'react/jsx-filename-extension': [1, { 9 | extensions: ['.js', '.jsx'], 10 | }], 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/getDisplayName.js: -------------------------------------------------------------------------------- 1 | // https://github.com/jurassix/react-display-name/blob/master/src/getDisplayName.js 2 | export default function getDisplayName(Component) { 3 | return ( 4 | Component.displayName || 5 | Component.name || 6 | (typeof Component === 'string' ? Component : 'Component') 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /lib/getDisplayName.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = getDisplayName; 7 | // https://github.com/jurassix/react-display-name/blob/master/src/getDisplayName.js 8 | function getDisplayName(Component) { 9 | return Component.displayName || Component.name || (typeof Component === 'string' ? Component : 'Component'); 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | *.sw* 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 UniversalAvenue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import PropTypes from 'prop-types'; 3 | import React from 'react'; 4 | 5 | const plugin = {}; 6 | const functions = {}; 7 | 8 | const configurable = (key, defaultFn, argGetter = () => []) => { 9 | const apply = fn => (...args) => fn.apply(null, [...args, ...argGetter()]); 10 | plugin[key] = (fn) => { 11 | functions[key] = apply(fn); 12 | }; 13 | functions[key] = apply(defaultFn); 14 | return (...args) => functions[key].apply(null, args); 15 | }; 16 | 17 | const defaultContextTypes = () => ({}); 18 | export const exposeContextTypes = configurable('exposeContextTypes', 19 | defaultContextTypes, 20 | () => [PropTypes]); 21 | 22 | const merge = arr => _.assign.apply(_, [{}, ...arr]); 23 | const defaultComposeComponent = (Component, { 24 | styles = [], 25 | style = {}, 26 | ...rest 27 | }) => { 28 | const mergedStyle = merge([style].concat(styles)); 29 | return ; 30 | }; 31 | export const composeComponent = configurable('composeComponent', defaultComposeComponent); 32 | 33 | const defaultRenderChild = props => (Child, index) => 34 | ; 35 | 36 | export const renderChild = configurable('renderChild', defaultRenderChild); 37 | 38 | export { 39 | plugin, 40 | }; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-compose", 3 | "description": "Compose react components with a functional api", 4 | "main": "lib/index.js", 5 | "scripts": { 6 | "clean": "./node_modules/rimraf/bin.js lib", 7 | "test": "npm run build && ./node_modules/jest-cli/bin/jest.js", 8 | "build": "./node_modules/babel-cli/bin/babel.js src --out-dir lib", 9 | "pkgfiles": "./node_modules/pkgfiles/bin/pkgfiles.js", 10 | "prepublish": "npm run clean && npm run build", 11 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 12 | }, 13 | "files": [ 14 | "lib", 15 | "!lib/__tests__" 16 | ], 17 | "release": { 18 | "verifyConditions": "condition-circle" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/UniversalAvenue/react-compose.git" 23 | }, 24 | "keywords": [ 25 | "React", 26 | "compose" 27 | ], 28 | "author": "Daniel Werthén (https://github.com/danielwerthen)", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/UniversalAvenue/react-compose/issues" 32 | }, 33 | "homepage": "https://github.com/UniversalAvenue/react-compose#readme", 34 | "peerDependencies": { 35 | "react": ">=15.5.4" 36 | }, 37 | "devDependencies": { 38 | "babel-cli": "^6.6.5", 39 | "babel-eslint": "^7.2.3", 40 | "babel-plugin-lodash": "^3.2.11", 41 | "babel-preset-es2015": "^6.5.0", 42 | "babel-preset-react": "^6.5.0", 43 | "babel-preset-stage-0": "^6.5.0", 44 | "condition-circle": "^1.2.0", 45 | "cz-conventional-changelog": "^2.0.0", 46 | "enzyme": "^2.3.0", 47 | "eslint": "^3.19.0", 48 | "eslint-config-airbnb": "^15.0.1", 49 | "eslint-plugin-import": "^2.3.0", 50 | "eslint-plugin-jsx-a11y": "^5.0.3", 51 | "eslint-plugin-react": "^7.0.1", 52 | "jest-cli": "^20.0.4", 53 | "pkgfiles": "^2.3.0", 54 | "react": ">=15.5.4", 55 | "react-addons-test-utils": "^15.5.1", 56 | "react-dom": "^15.5.4", 57 | "redux-thunk": "^2.1.0", 58 | "rimraf": "^2.4.4", 59 | "semantic-release": "^6.3.6" 60 | }, 61 | "jest": { 62 | "roots": [ 63 | "lib" 64 | ] 65 | }, 66 | "config": { 67 | "commitizen": { 68 | "path": "./node_modules/cz-conventional-changelog" 69 | } 70 | }, 71 | "dependencies": { 72 | "classnames": "^2.2.3", 73 | "lodash": "^4.0.0", 74 | "prop-types": "^15.5.10" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | import getDisplayName from './getDisplayName'; 4 | 5 | const defaultMergeProps = (stateProps, dispatchProps, parentProps) => ({ 6 | ...parentProps, 7 | ...stateProps, 8 | ...dispatchProps, 9 | }); 10 | 11 | function createDispatchProps(dispatchers, dispatch) { 12 | if (_.isFunction(dispatchers)) { 13 | return dispatchers(dispatch); 14 | } 15 | function dispatchHandler(fn) { 16 | const action = _.isString(fn) ? { type: fn } : fn; 17 | return () => dispatch(action); 18 | } 19 | return _.reduce(dispatchers, (sum, fn, key) => 20 | Object.assign(sum, { 21 | [key]: dispatchHandler(fn), 22 | }), 23 | {}); 24 | } 25 | 26 | function wrapLifeCycle(fn) { 27 | if (!_.isFunction(fn)) { 28 | const action = _.isString(fn) ? { type: fn } : fn; 29 | return function wrappedStaticLifeCycleMethod() { 30 | this.dispatch(action); 31 | }; 32 | } 33 | return fn; 34 | } 35 | 36 | 37 | export default function connect(reducer, 38 | dispatchers, 39 | lifeCycle = {}, 40 | merge = defaultMergeProps, 41 | middlewares = []) { 42 | return (Component) => { 43 | class Connected extends React.Component { 44 | constructor(props) { 45 | super(props); 46 | this.state = reducer(props, {}); 47 | this.dispatch = this.dispatch.bind(this); 48 | const middlewareAPI = { 49 | getState: () => this.state, 50 | dispatch: this.dispatch, 51 | }; 52 | const chain = middlewares.map(middleware => middleware(middlewareAPI)); 53 | this.dispatch = [...chain].reduceRight((a, fn) => fn(a), this.dispatch); 54 | this.dispatchProps = createDispatchProps(dispatchers, this.dispatch); 55 | } 56 | componentWillReceiveProps(nextProps) { 57 | this.setState(_.omitBy(nextProps, (val, key) => 58 | val === this.state[key])); 59 | } 60 | getProps() { 61 | return merge(this.state, this.dispatchProps, this.props); 62 | } 63 | dispatch(action) { 64 | const nextState = reducer(this.state, action); 65 | if (nextState !== this.state) { 66 | this.setState(nextState); 67 | } 68 | return action; 69 | } 70 | render() { 71 | return ; 72 | } 73 | } 74 | Connected.displayName = `connect(${getDisplayName(Component)})`; 75 | _.each(lifeCycle, (fn, key) => Object.assign(Connected.prototype, { 76 | [key]: wrapLifeCycle(fn), 77 | })); 78 | return Connected; 79 | }; 80 | } 81 | 82 | export function applyMiddleware(...wares) { 83 | return (...args) => connect.apply(null, 84 | [args[0], args[1], args[2] || {}, args[3] || defaultMergeProps, wares]); 85 | } 86 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.plugin = exports.renderChild = exports.composeComponent = exports.exposeContextTypes = undefined; 7 | 8 | var _assign2 = require('lodash/assign'); 9 | 10 | var _assign3 = _interopRequireDefault(_assign2); 11 | 12 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 13 | 14 | var _propTypes = require('prop-types'); 15 | 16 | var _propTypes2 = _interopRequireDefault(_propTypes); 17 | 18 | var _react = require('react'); 19 | 20 | var _react2 = _interopRequireDefault(_react); 21 | 22 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 23 | 24 | function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; } 25 | 26 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 27 | 28 | var plugin = {}; 29 | var functions = {}; 30 | 31 | var configurable = function configurable(key, defaultFn) { 32 | var argGetter = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : function () { 33 | return []; 34 | }; 35 | 36 | var apply = function apply(fn) { 37 | return function () { 38 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 39 | args[_key] = arguments[_key]; 40 | } 41 | 42 | return fn.apply(null, [].concat(args, _toConsumableArray(argGetter()))); 43 | }; 44 | }; 45 | plugin[key] = function (fn) { 46 | functions[key] = apply(fn); 47 | }; 48 | functions[key] = apply(defaultFn); 49 | return function () { 50 | for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 51 | args[_key2] = arguments[_key2]; 52 | } 53 | 54 | return functions[key].apply(null, args); 55 | }; 56 | }; 57 | 58 | var defaultContextTypes = function defaultContextTypes() { 59 | return {}; 60 | }; 61 | var exposeContextTypes = exports.exposeContextTypes = configurable('exposeContextTypes', defaultContextTypes, function () { 62 | return [_propTypes2.default]; 63 | }); 64 | 65 | var merge = function merge(arr) { 66 | return _assign3.default.apply(_assign3.default.apply.placeholder, [{}].concat(_toConsumableArray(arr))); 67 | }; 68 | var defaultComposeComponent = function defaultComposeComponent(Component, _ref) { 69 | var _ref$styles = _ref.styles, 70 | styles = _ref$styles === undefined ? [] : _ref$styles, 71 | _ref$style = _ref.style, 72 | style = _ref$style === undefined ? {} : _ref$style, 73 | rest = _objectWithoutProperties(_ref, ['styles', 'style']); 74 | 75 | var mergedStyle = merge([style].concat(styles)); 76 | return _react2.default.createElement(Component, _extends({ style: mergedStyle }, rest)); 77 | }; 78 | var composeComponent = exports.composeComponent = configurable('composeComponent', defaultComposeComponent); 79 | 80 | var defaultRenderChild = function defaultRenderChild(props) { 81 | return function (Child, index) { 82 | return _react2.default.createElement(Child, _extends({}, props, { key: (Child.displayName || '') + index })); 83 | }; 84 | }; 85 | 86 | var renderChild = exports.renderChild = configurable('renderChild', defaultRenderChild); 87 | 88 | exports.plugin = plugin; -------------------------------------------------------------------------------- /src/__tests__/connect-test.js: -------------------------------------------------------------------------------- 1 | jest.autoMockOff(); 2 | 3 | const React = require('react'); 4 | const { mount } = require('enzyme'); 5 | const thunk = require('redux-thunk').default; 6 | 7 | const connect = require('../connect').default; 8 | const applyMiddleware = require('../connect').applyMiddleware; 9 | 10 | describe('connect', () => { 11 | it('creates a valid component', () => { 12 | const reducer = jest.fn(state => state); 13 | const Component = connect(reducer, { onClick: 'doClick' })('button'); 14 | const wrapper = mount(); 15 | wrapper.find('button').simulate('click'); 16 | expect(reducer.mock.calls[0][0]).toEqual({}); 17 | expect(reducer.mock.calls[1][1].type).toEqual('doClick'); 18 | }); 19 | it('changes a prop as a result', () => { 20 | const Button = props => ; 30 | }; 31 | ``` 32 | 33 | Now if a developer would like to manipulate the style of `ButtonComponent` from 34 | the outside, it would have to be changed accordingly: 35 | 36 | ```javascript 37 | const ButtonComponent = props => { 38 | const { 39 | onClick, 40 | style, 41 | label, 42 | } = props; 43 | return ; 44 | }; 45 | ``` 46 | 47 | On the other hand, if all props should be passed down to the `button` element, 48 | the following is much more useful: 49 | 50 | ```javascript 51 | const ButtonComponent = props => { 52 | const { 53 | label, 54 | } = props; 55 | return ; 56 | }; 57 | ``` 58 | With **react-compose**, the above would be written as: 59 | 60 | ```javascript 61 | const labelToChildren = ({ label }) => ({ children: label }); 62 | 63 | const ButtonComponent = compose(labelToChildren)('button'); 64 | ``` 65 | Leaving much less room for breaking the rules of extendability and resuability. 66 | The CustomComponent should essentially work as you would expect that the basic 67 | html elements does, `ButtonComponent` ~ `button`, beyond of course the added 68 | behavior. 69 | 70 | As an extra bonus, it is also more straight forward to test the encapsulated 71 | behavior rather than the component as a whole. 72 | 73 | ```javascript 74 | describe('labelToChildren', () => { 75 | it('should pass whatever input label as children', () => { 76 | expect(labelToChildren({ label: 'string' }).children).toEqual('string'); 77 | }); 78 | }); 79 | ``` 80 | 81 | Finally, the heart of **react-compose**, is finding those elementary patterns 82 | that are present in your application. In this case, we can create a nice higher 83 | order function for the `labelToChildren` logic. 84 | 85 | ```javascript 86 | const mixProp = (from, to) => props => ({ [to]: props[from] }); 87 | const labelToChildren = mixProp('label', 'children'); 88 | ``` 89 | 90 | ## Installation 91 | 92 | Install package, and check that you are using a matching version of React (^0.14) 93 | 94 | ```bash 95 | npm install -s react-compose 96 | ``` 97 | 98 | ## API 99 | 100 | Example api usage: 101 | 102 | ```javascript 103 | import { compose } from 'react-compose'; 104 | 105 | const constantProper = { 106 | age: 15, 107 | }; 108 | 109 | const dynamicProper = props => { 110 | return { 111 | children: `The cat is ${props.age} years old`, 112 | }; 113 | }; 114 | 115 | const Cat = compose(constantProper, dynamicProper)('p'); 116 | 117 | // =>

The cat is 15 years old

; 118 | ``` 119 | 120 | Specialized style composing 121 | 122 | ```javascript 123 | import { compose, styles } from 'react-compose'; 124 | 125 | const constantStyle = { 126 | background: 'red', 127 | }; 128 | const dynamicStyle = ({ isActive }) => (!isActive && { 129 | display: 'none', 130 | }); 131 | 132 | const Component = compose(styles(constantStyle, dynamicStyle))('p'); 133 | 134 | return (props) => { 135 | return Some text; 136 | }; 137 | ``` 138 | 139 | Stacking custom components 140 | 141 | ```javascript 142 | import { compose } from 'react-compose'; 143 | 144 | const Cat = props => { 145 | return

The cat is {props.age} years old

; 146 | }; 147 | 148 | const injectAge = { 149 | age: 5, 150 | }; 151 | 152 | const Composed = compose(injectAge)(Cat); 153 | 154 | // =>

The cat is 5 years old

155 | ``` 156 | 157 | Composing complex children values 158 | 159 | ```javascript 160 | import { compose, children } from 'react-compose'; 161 | 162 | const AgeInfo = props => { 163 | return

Age: {props.age} years

; 164 | }; 165 | 166 | const LengthInfo = props => { 167 | return

Length: {props.length} cm

; 168 | }; 169 | 170 | const HeightInfo = props => { 171 | return

Height: {props.height} cm

; 172 | }; 173 | 174 | const Info = compose(children(AgeInfo, LengthInfo, HeightInfo))('div'); 175 | 176 | const dogData = { 177 | age: 5, 178 | length: 250, 179 | height: 150, 180 | }; 181 | 182 | const DogInfo = compose(dogData)(Info); 183 | 184 | // =>
185 | //

Age: 5

186 | //

Length: 250

187 | //

Height: 150

188 | //
189 | ``` 190 | 191 | Composing classNames, using the awesome [classnames](https://github.com/JedWatson/classnames) lib 192 | 193 | ```javascript 194 | import { compose, classNames } from 'react-compose'; 195 | 196 | const btnClassNames = classNames('btn', 197 | ({ pressed }) => pressed && 'btn-pressed', 198 | ({ hover }) => hover && 'btn-hover'); 199 | 200 | const Button = compose(btnClassNames)('button'); 201 | 202 | // pressed: true =>