├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src ├── LinkBuilders.js ├── LinkValue.js └── index.js ├── test ├── .eslintrc ├── LinkBuilders.spec.js ├── LinkValue.spec.js └── index.spec.js └── wallaby.conf.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "rules": { 4 | "react/jsx-filename-extension": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /lib 4 | npm-debug.log 5 | 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /src 3 | /test 4 | /.babelrc 5 | /.editorconfig 6 | /.eslintrc 7 | /.gitignore 8 | /.travis.yml 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | script: npm run ci 5 | before_deploy: npm run build 6 | deploy: 7 | provider: npm 8 | email: andrew.schrauf@gmail.com 9 | api_key: 10 | secure: JP8C6kqHa3JHtrHhiafUNnZhOLyKYdiknh9K2Xo6/lIofeOjaWdUwnCPGdS2j9PdMUuyRSV1HoTzwwNqOpjues7UmmdRqyCkB8XHNyUfairFXZsBEYc0Y2oSEhy/NXxfGzfelEEumFOhUVM+TeRtB01yTzHlO7MJQw8UlORJ0R1rWMtO6bfm+AC6nZqOIW8fK4Qs4w3mEEfiL83Zr+ecxV3xCGJ7GyTH+5+Ttry/NQl/8GhgE6/pHf0IvudG1RDKooO/WyBxrata6wx0rQU7ODeO9bxg3wnteqfKVz2MadqSDY19tFgyLvNTMgQoxfrdSbTZrHlhI5FTRnDVtOUVdra2beCAqh4DcAaogHfbSxbL8Qwc2S14tj1MVV/yWtg/sp04mjFubhs7cN4lC9plDP7KZS3TdcR7HsK1P3sGhwdu/0FMNfTrNH/S+tMHyjPqgqtdBrw8fFj65jtByTsa0zZ9IczFg6O6IIGxAXz3iJL9umH79WONO96PpZ1Rf9CAqkXZh4bdkUM7w7r7EiP8hF+ZiSoNLeixndmAm+7WgL3jNnF3K+s14x9z7VQ1MjxHOSKUl0id0Ul7mQDdDUO46N6YhggVcT6F5O+zg3TG4qdman85syzqL+HMkW3yscSrBBy59SV2aEJgvxQUuQN6Do2Z/5WZedgdciBfNJAQ17w= 11 | on: 12 | tags: true 13 | repo: drewschrauf/link-value 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LinkValue 2 | 3 | [![Build Status](https://travis-ci.org/drewschrauf/link-value.svg?branch=master)](https://travis-ci.org/drewschrauf/link-value) 4 | [![Coverage Status](https://coveralls.io/repos/github/drewschrauf/link-value/badge.svg?branch=master)](https://coveralls.io/github/drewschrauf/link-value?branch=master) 5 | [![npm version](https://badge.fury.io/js/link-value.svg)](https://badge.fury.io/js/link-value) 6 | 7 | LinkValue is a tiny library designed for making complex forms in React easier to build. It allows you to take a large, complicated state object and create `value`/`onChange` pairs for smaller chunks of that object. Passing these pairs down to child components allows you to break your form up into small, manageable pieces that remain decoupled from the larger form. 8 | 9 | ## Example 10 | 11 | Let's solve the classic problem of making a form to edit items in a list of values. Our container holds the state and renders a Form taking a `value` (a list of books) and an `onChange` listener. 12 | 13 | ```javascript 14 | class Container extends Component { 15 | constructor() { 16 | super() 17 | this.state = { 18 | books: [ 19 | {title: 'The Very Hungry Caterpillar', author: 'Eric Carle'}, 20 | {title: 'Possum Magic', author: 'Mem Fox'}, 21 | // ...etc 22 | ] 23 | } 24 | } 25 | 26 | // update the state with a new books array 27 | handleChange(newBooks) { 28 | this.setState({books: newBooks}) 29 | } 30 | 31 | // render the Form component taking the array of books and our change handler 32 | render() { 33 | return ( 34 |
35 | ) 36 | } 37 | } 38 | ``` 39 | 40 | Now we can make the `Form` component. This is taking the entire list of books as its `value` and will emit the entire list of books on each change. Note the decorator we're using here to add LinkValue to the component then using it with `{...this.props.makeLink(path)}`. 41 | 42 | ```javascript 43 | import linkValue from 'link-value' 44 | 45 | @linkValue 46 | class Form extends Component { 47 | render() { 48 | return ( 49 | 54 | ) 55 | } 56 | } 57 | ``` 58 | 59 | That `makeLink` call in the render of `BookForm` just made a new `value`/`onChange` pair from the `value` and `onChange` supplied to the Form. Each of these pairs contains a single book as a value (found using the index (`i`) passed to `makeLink`) and an `onChange` handler that knows how to update just that book. Now we can use these values to render the actual book form: 60 | 61 | ```javascript 62 | import linkValue from 'link-value' 63 | 64 | @linkValue 65 | class BookForm extends Component { 66 | render() { 67 | return ( 68 |
69 | Title: 70 | Author: 71 |
72 | ) 73 | } 74 | } 75 | ``` 76 | 77 | Again, we've used `makeLink` to make `value`/`onChange` pairs for individual fields in a single book. Editing one of these fields will trigger the `onChange` for the `BookForm`, which triggers the `onChange` in the `Form` which triggers the `handleChange` up in the `Container`. At each step, our values are combined so that by the time the event has reached the `Container` component, the `handleChange` function receives the entire array of books with just a small part modified. 78 | 79 | ## API 80 | 81 | The primary way to use LinkValue is through the decorator. This adds three extra properties to your component, `makeLink`, `makeMergeLink` and `makeCheckedLink`, each taking a path. These are just the following functions with the `value` and `onChange` bound to the `value` and `onChange` provided to the component. The functions are also available with `import { makeLink, makeMergeLink, makeCheckedLink } from 'link-value'`. 82 | 83 | ### makeLink(value, onChange, ...path) 84 | 85 | Generates an object containing a new `value` and `onChange` for a given path. 86 | 87 | ```javascript 88 | const books = [{title: 'Old Title', author: 'Old Author'}, ...] 89 | const logChange = newVal => console.log(newVal) 90 | 91 | const link = makeLink(books, logChange, 0, 'title') 92 | console.log(link.value) // logs 'Old Title' 93 | link.onChange('New Title') //logs [{title: 'New Title', author: 'Old Author'}, ...] 94 | ``` 95 | 96 | ### makeMergeLink(value, onChange, ...path) 97 | 98 | Sometimes you don't want the value passed to the `onChange` to completely replace the contents in the `value`. In these cases, you want to use `makeMergeLink` to add the properties to the object instead. 99 | 100 | ```javascript 101 | const weather = [ 102 | {city: 'Melbourne', high: 28, low: 12}, 103 | {city: 'Sydney', high: 24, low: 15}, 104 | ... 105 | ] 106 | const logChange = newVal => console.log(newVal) 107 | 108 | const link = makeMergeLink(value, logChange, 1) 109 | console.log(link.value) // logs {city: 'Sydney', high: 24, low: 15} 110 | link.onChange({high: 30}) // just update the high, logs... 111 | //[ 112 | // {city: 'Melbourne', high: 28, low: 12}, 113 | // {city: 'Sydney', high: 30, low: 15}, // (Note the new high!) 114 | // ... 115 | //] 116 | ``` 117 | 118 | If we tried to use `makeLink` here, the 'Sydney' object would have been completely replaced by an object only containing the 'high'. Using `makeMergeLink` updated the 'high' on the existing object. 119 | 120 | ### makeCheckedLink(value, onChange, ...path) 121 | 122 | Checkboxes are handled a little differently to other inputs in React. For this reason, `makeCheckedLink` is provided alongside `makeLink` to deal with these slight differences. Instead of a `value`/`onChange` pair being produced, a `checked`/`onChange` pair is produced instead. 123 | 124 | ## Contribute 125 | 126 | Pull requests welcome. Please make sure tests pass with: 127 | 128 | ``` 129 | npm test 130 | ``` 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "link-value", 3 | "version": "0.1.2", 4 | "description": "React forms made simple", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "babel -d lib src", 8 | "test": "mocha -r jsdom-global/register --compilers js:babel-register --recursive", 9 | "cover": "istanbul cover node_modules/mocha/bin/_mocha -- -r jsdom-global/register --compilers js:babel-register --recursive test", 10 | "watch": "mocha -w -r jsdom-global/register --compilers js:babel-register --recursive test", 11 | "lint": "eslint src test", 12 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/.bin/coveralls", 13 | "ci": "npm run lint && npm run cover && npm run coveralls" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "forms", 18 | "ValueLink" 19 | ], 20 | "author": "Drew Schrauf ", 21 | "repository": "drewschrauf/link-value", 22 | "license": "ISC", 23 | "peerDependecies": { 24 | "react": ">=0.14.0" 25 | }, 26 | "devDependencies": { 27 | "automock": "drewschrauf/automock", 28 | "babel-cli": "^6.11.4", 29 | "babel-core": "^6.11.4", 30 | "babel-preset-es2015": "^6.9.0", 31 | "babel-preset-react": "^6.11.1", 32 | "babel-register": "^6.11.6", 33 | "chai": "^3.5.0", 34 | "chai-enzyme": "^0.5.0", 35 | "cheerio": "^0.20.0", 36 | "coveralls": "^2.11.12", 37 | "enzyme": "^2.4.1", 38 | "eslint": "^3.2.2", 39 | "eslint-config-airbnb": "^10.0.0", 40 | "eslint-plugin-import": "^1.12.0", 41 | "eslint-plugin-jsx-a11y": "^2.0.1", 42 | "eslint-plugin-react": "^6.0.0", 43 | "istanbul": "^1.0.0-alpha.2", 44 | "jsdom": "^9.4.1", 45 | "jsdom-global": "^2.0.0", 46 | "mocha": "^3.0.0", 47 | "react": "^15.3.0", 48 | "react-addons-test-utils": "^15.3.0", 49 | "react-dom": "^15.3.0", 50 | "sinon": "^1.17.5", 51 | "sinon-chai": "^2.8.0" 52 | }, 53 | "dependencies": { 54 | "lodash": "^4.14.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/LinkBuilders.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/fp/get'; 2 | import getOr from 'lodash/fp/getOr'; 3 | import set from 'lodash/fp/set'; 4 | import extend from 'lodash/fp/extend'; 5 | 6 | // automatically unbox synthetic events to raw values 7 | function unboxValue(e) { 8 | return getOr(e, 'target.value', e); 9 | } 10 | 11 | // automatically unbox synthetic checked events to raw values 12 | function unboxChecked(e) { 13 | return getOr(e, 'target.checked', e); 14 | } 15 | 16 | // replace the value with the new value 17 | export function makeLink(value, onChange, ...path) { 18 | if (!path.length) { 19 | return { 20 | value, 21 | onChange: newVal => onChange(unboxValue(newVal)), 22 | }; 23 | } 24 | return { 25 | value: get(path, value), 26 | onChange: newVal => onChange(set(path, unboxValue(newVal), value)), 27 | }; 28 | } 29 | 30 | // merge the new value into the existing value 31 | export function makeMergeLink(value, onChange, ...path) { 32 | if (!path.length) { 33 | return { 34 | value, 35 | onChange: newVal => onChange(extend(value, unboxValue(newVal))), 36 | }; 37 | } 38 | 39 | return { 40 | value: get(path, value), 41 | onChange: (newVal) => onChange( 42 | set(path, extend(get(path, value), unboxValue(newVal)), value) 43 | ), 44 | }; 45 | } 46 | 47 | export function makeCheckedLink(value, onChange, ...path) { 48 | const link = makeLink(value, onChange, ...path); 49 | return { 50 | checked: link.value, 51 | onChange: newVal => link.onChange(unboxChecked(newVal)), 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/LinkValue.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; // eslint-disable-line import/no-extraneous-dependencies 2 | import { makeLink, makeMergeLink, makeCheckedLink } from './LinkBuilders'; 3 | 4 | export default (Component) => { 5 | const LinkValue = (props) => { 6 | const { value, onChange } = props; 7 | const addProps = value != null && !!onChange; 8 | 9 | const additionalProps = addProps ? { 10 | makeLink: makeLink.bind(null, value, onChange), 11 | makeMergeLink: makeMergeLink.bind(null, value, onChange), 12 | makeCheckedLink: makeCheckedLink.bind(null, value, onChange), 13 | } : {}; 14 | return ; 15 | }; 16 | LinkValue.propTypes = { 17 | value: React.PropTypes.any, 18 | onChange: React.PropTypes.func, 19 | }; 20 | return LinkValue; 21 | }; 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import LinkValue from './LinkValue'; 2 | 3 | export { makeLink, makeMergeLink, makeCheckedLink } from './LinkBuilders'; 4 | export default LinkValue; 5 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "import/no-extraneous-dependencies": 0, 7 | "no-underscore-dangle": 0 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/LinkBuilders.spec.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import sinonChai from 'sinon-chai'; 4 | import { makeLink, makeMergeLink, makeCheckedLink } from '../src/LinkBuilders'; 5 | 6 | chai.use(sinonChai); 7 | 8 | const v = { a: 1, b: { c: 2, d: 3 } }; 9 | const vc = { a: true, b: { c: false, d: true } }; 10 | 11 | describe('LinkBuilder', () => { 12 | let c; 13 | beforeEach(() => { 14 | c = sinon.spy(); 15 | }); 16 | 17 | describe('makeLink', () => { 18 | it('should be a function', () => { 19 | expect(makeLink).to.be.a('function'); 20 | }); 21 | 22 | describe('with path', () => { 23 | it('should return a value and onChange', () => { 24 | const r = makeLink(v, c, 'a'); 25 | expect(r.value).to.equal(1); 26 | expect(r.onChange).to.be.a('function'); 27 | }); 28 | 29 | it('should propagate events to supplied onChange', () => { 30 | const r = makeLink(v, c, 'a'); 31 | r.onChange(9); 32 | expect(c).to.have.been.calledWith({ a: 9, b: { c: 2, d: 3 } }); 33 | }); 34 | 35 | it('should unbox onChange values from synthetic events', () => { 36 | const r = makeLink(v, c, 'a'); 37 | r.onChange({ target: { value: 9 } }); 38 | expect(c).to.have.been.calledWith({ a: 9, b: { c: 2, d: 3 } }); 39 | }); 40 | }); 41 | 42 | describe('without path', () => { 43 | it('should return a value and onChange', () => { 44 | const r = makeLink(v, c); 45 | expect(r.value).to.eql(v); 46 | expect(r.onChange).to.be.a('function'); 47 | }); 48 | 49 | it('should propagate events to supplied onChange', () => { 50 | const r = makeLink(v, c); 51 | r.onChange(9); 52 | expect(c).to.have.been.calledWith(9); 53 | }); 54 | 55 | it('should unbox onChange values from synthetic events', () => { 56 | const r = makeLink(v, c); 57 | r.onChange({ target: { value: 9 } }); 58 | expect(c).to.have.been.calledWith(9); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('makeCheckedLink', () => { 64 | it('should be a function', () => { 65 | expect(makeCheckedLink).to.be.a('function'); 66 | }); 67 | 68 | it('should return a checked and onChange', () => { 69 | const r = makeCheckedLink(vc, c, 'a'); 70 | expect(r.checked).to.equal(true); 71 | expect(r.onChange).to.be.a('function'); 72 | }); 73 | 74 | it('should propagate events to supplied onChange', () => { 75 | const r = makeCheckedLink(vc, c, 'a'); 76 | r.onChange(false); 77 | expect(c).to.have.been.calledWith({ a: false, b: { c: false, d: true } }); 78 | }); 79 | 80 | it('should unbox onChange values from synthetic events', () => { 81 | const r = makeCheckedLink(vc, c, 'a'); 82 | r.onChange({ target: { checked: false } }); 83 | expect(c).to.have.been.calledWith({ a: false, b: { c: false, d: true } }); 84 | }); 85 | }); 86 | 87 | describe('makeMergeLink', () => { 88 | it('should be a function', () => { 89 | expect(makeMergeLink).to.be.a('function'); 90 | }); 91 | 92 | describe('with path', () => { 93 | it('should return a value and onChange when given a path', () => { 94 | const r = makeMergeLink(v, c, 'a'); 95 | expect(r.value).to.equal(1); 96 | expect(r.onChange).to.be.a('function'); 97 | }); 98 | 99 | it('should propagate events to supplied onChange when given a path', () => { 100 | const r = makeMergeLink(v, c, 'b'); 101 | r.onChange({ c: 9 }); 102 | expect(c).to.have.been.calledWith({ a: 1, b: { c: 9, d: 3 } }); 103 | }); 104 | 105 | it('should unbox onChange values from synthetic events when given a path', () => { 106 | const r = makeMergeLink(v, c, 'b'); 107 | r.onChange({ target: { value: { c: 9 } } }); 108 | expect(c).to.have.been.calledWith({ a: 1, b: { c: 9, d: 3 } }); 109 | }); 110 | }); 111 | 112 | describe('without path', () => { 113 | it('should return a value and onChange when not given a path', () => { 114 | const r = makeMergeLink(v, c); 115 | expect(r.value).to.eql(v); 116 | expect(r.onChange).to.be.a('function'); 117 | }); 118 | 119 | it('should propagate events to supplied onChange when not given a path', () => { 120 | const r = makeMergeLink(v, c); 121 | r.onChange({ b: 9 }); 122 | expect(c).to.have.been.calledWith({ a: 1, b: 9 }); 123 | }); 124 | 125 | it('should unbox onChange values from synthetic events when given a path', () => { 126 | const r = makeMergeLink(v, c); 127 | r.onChange({ target: { value: { b: 9 } } }); 128 | expect(c).to.have.been.calledWith({ a: 1, b: 9 }); 129 | }); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /test/LinkValue.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import chai, { expect } from 'chai'; 3 | import { mount } from 'enzyme'; 4 | import sinon from 'sinon'; 5 | import sinonChai from 'sinon-chai'; 6 | import chaiEnzyme from 'chai-enzyme'; 7 | import automock from 'automock'; 8 | 9 | chai.use(sinonChai); 10 | chai.use(chaiEnzyme()); 11 | 12 | automock.setStubCreator(() => sinon.spy()); 13 | 14 | describe('LinkValue', () => { 15 | let c; 16 | let linkValue; 17 | let LinkValueModule; 18 | let TestComponent; 19 | beforeEach(() => { 20 | LinkValueModule = automock.require('../src/LinkValue', { 21 | passThru: ['react'], 22 | }); 23 | linkValue = LinkValueModule.default; 24 | 25 | TestComponent = linkValue(props => 26 |
27 | ); 28 | 29 | c = sinon.spy(); 30 | }); 31 | 32 | describe('default', () => { 33 | it('should return a function', () => { 34 | expect(linkValue).to.be.a('function'); 35 | }); 36 | 37 | it('should render child component', () => { 38 | const root = mount(); 39 | const child = root.find('div'); 40 | expect(child.length).to.equal(1); 41 | }); 42 | 43 | it('should pass original props when not passing utilities', () => { 44 | const root = mount(); 45 | const child = root.find('div'); 46 | expect(child.prop('test')).to.equal('test'); 47 | }); 48 | 49 | it('should provide utility functions if value and onChange are present', () => { 50 | const root = mount(); 51 | const child = root.find('div').first(); 52 | expect(child).to.have.prop('makeLink'); 53 | expect(child).to.have.prop('makeMergeLink'); 54 | expect(child).to.have.prop('makeCheckedLink'); 55 | }); 56 | 57 | it('should not provide utility functions if value and no onChange is present', () => { 58 | const root = mount(); 59 | const child = root.find('div').first(); 60 | expect(child).to.not.have.prop('makeLink'); 61 | expect(child).to.not.have.prop('makeMergeLink'); 62 | expect(child).to.not.have.prop('makeCheckedLink'); 63 | }); 64 | 65 | it('should provide utility functions if onChange and no value is present', () => { 66 | const root = mount(); 67 | const child = root.find('div').first(); 68 | expect(child).to.not.have.prop('makeLink'); 69 | expect(child).to.not.have.prop('makeMergeLink'); 70 | expect(child).to.not.have.prop('makeCheckedLink'); 71 | }); 72 | 73 | ['makeLink', 'makeMergeLink', 'makeCheckedLink'].forEach(type => { 74 | it(`should bind value and onChange to ${type} function`, () => { 75 | const root = mount(); 76 | const child = root.find('div').first(); 77 | const mockMakeLink = LinkValueModule.__stubs__['./LinkBuilders'][type]; 78 | child.prop(type)('a'); 79 | 80 | // makeLink should be bound 81 | expect(mockMakeLink.getCall(0).args[0]).to.eql({ a: 'test' }); 82 | expect(mockMakeLink.getCall(0).args[1]).to.be.a('function'); 83 | expect(mockMakeLink.getCall(0).args[2]).to.be.equal('a'); 84 | 85 | // the passed function should be our original function 86 | mockMakeLink.getCall(0).args[1]('new'); 87 | expect(c).to.have.been.calledWith('new'); 88 | }); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import LinkValue, { makeLink, makeMergeLink, makeCheckedLink } from '../src'; 3 | 4 | describe('Root', () => { 5 | it('should export LinkValue by default', () => { 6 | expect(LinkValue).to.be.a('function'); 7 | }); 8 | 9 | it('should reexport LinkBuilders', () => { 10 | expect(makeLink).to.be.a('function'); 11 | expect(makeMergeLink).to.be.a('function'); 12 | expect(makeCheckedLink).to.be.a('function'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /wallaby.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (wallaby) { 2 | return { 3 | files: ['src/**/*.js'], 4 | tests: ['test/**/*.spec.js'], 5 | env: { 6 | type: 'node' 7 | }, 8 | compilers: { 9 | '**/*.js': wallaby.compilers.babel() 10 | }, 11 | setup: function (wallaby) { 12 | require('jsdom-global')() 13 | } 14 | } 15 | } 16 | --------------------------------------------------------------------------------