├── src ├── index.js ├── components │ ├── index.js │ ├── field │ │ ├── __test │ │ │ ├── field.getBuiltInComponent.test.js │ │ │ ├── field.getComponent.test.js │ │ │ ├── field.componentDidMount.test.js │ │ │ └── field.test.js │ │ └── field.js │ ├── form │ │ ├── __test │ │ │ ├── form.test.js │ │ │ ├── form.handleFieldChange.test.js │ │ │ └── form__field.test.js │ │ └── form.js │ ├── NestedForm │ │ └── NestedForm.js │ └── text-field │ │ └── text-field.js └── helpers │ └── index.js ├── README.md ├── .npmignore ├── examples ├── helpers │ ├── index.js │ └── validateAsync.js ├── index.js ├── index.html ├── index.css ├── components │ ├── App │ │ └── App.css │ ├── app │ │ └── app.js │ └── RegisterMeal │ │ ├── RegisterMeal.css │ │ └── RegisterMeal.js └── webpack.config.js ├── .babelrc ├── .gitignore ├── .eslintrc.js ├── LICENSE └── package.json /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # functional-forms 2 | Dynamic declarative forms for React 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc.js 3 | .gitignore 4 | examples 5 | src 6 | -------------------------------------------------------------------------------- /examples/helpers/index.js: -------------------------------------------------------------------------------- 1 | export { validateAsync } from './validateAsync'; 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-class-properties" 4 | ], 5 | "presets": [ "es2015", "es2017", "stage-3"] 6 | } 7 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { Form } from './form/form'; 2 | export { NestedForm } from './NestedForm/NestedForm'; 3 | export { TextField } from './text-field/text-field'; 4 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import h from 'react-hyperscript'; 3 | import { App } from './components/App/App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render(h(App), document.querySelector('#app')); 7 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Functional Forms Examples 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | html, body, #app { 8 | bottom: 0; 9 | font-family: sans-serif; 10 | font-size: 16px; 11 | left: 0; 12 | overflow: hidden; 13 | position: absolute; 14 | right: 0; 15 | top: 0; 16 | } 17 | -------------------------------------------------------------------------------- /examples/components/App/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | align-items: center; 3 | background-color: #f5f5f5; 4 | display: flex; 5 | flex: 1 1 auto; 6 | flex-direction: column; 7 | height: 100vh; 8 | overflow: hidden; 9 | } 10 | 11 | .App__content { 12 | display: flex; 13 | flex: 1 1 auto; 14 | flex-direction: column; 15 | max-width: 768px; 16 | overflow-x: hidden; 17 | overflow-y: auto; 18 | width: 100%; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/field/__test/field.getBuiltInComponent.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import h from 'react-hyperscript'; 3 | import { shallow } from 'enzyme'; 4 | import { TextField } from '../../text-field/text-field'; 5 | import { Field } from '../field'; 6 | 7 | test('should return TextField when type is equal to "text"', (t) => { 8 | const component = shallow(h(Field, { 9 | field: { id: 'a', component: 'text' }, 10 | onFieldChange: () => {}, 11 | })); 12 | const expected = TextField; 13 | const result = component.instance().getBuiltInComponent(); 14 | t.is(result, expected); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/components/app/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import h from 'react-hyperscript'; 3 | import StylePropType from 'react-style-proptype'; 4 | import { RegisterMeal } from '../RegisterMeal/RegisterMeal'; 5 | import './App.css'; 6 | 7 | export class App extends React.Component { 8 | static propTypes = { 9 | className: React.PropTypes.string, 10 | style: StylePropType, 11 | }; 12 | 13 | render() { 14 | return h('.App', { 15 | className: this.props.className, 16 | style: this.props.style, 17 | }, [ 18 | h('.App__content', [ 19 | h(RegisterMeal), 20 | ]), 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export const hideIf = condition => showIf(!condition); 2 | 3 | export function makeCancelable(promise) { 4 | let hasCanceled = false; 5 | 6 | const wrappedPromise = new Promise((resolve, reject) => { 7 | promise.then(val => 8 | (hasCanceled ? reject({ isCanceled: true }) : resolve(val)), 9 | ); 10 | promise.catch(error => 11 | (hasCanceled ? reject({ isCanceled: true }) : reject(error)), 12 | ); 13 | }); 14 | 15 | return { 16 | promise: wrappedPromise, 17 | cancel() { 18 | hasCanceled = true; 19 | }, 20 | }; 21 | } 22 | 23 | export function showIf(condition) { 24 | return result => (condition ? result() : null); 25 | } 26 | -------------------------------------------------------------------------------- /examples/helpers/validateAsync.js: -------------------------------------------------------------------------------- 1 | import includes from 'lodash/fp/includes'; 2 | 3 | const foods = [ 4 | 'Burgers', 5 | 'Pizza', 6 | 'Ramen', 7 | ]; 8 | 9 | export function validateAsync(delay, food) { 10 | return new Promise((resolve) => { 11 | const timeout = window.setTimeout(() => { 12 | window.clearTimeout(timeout); 13 | 14 | if (includes(food, foods)) { 15 | resolve({ 16 | type: 'success', 17 | message: `We have plenty of ${food}!`, 18 | }); 19 | } 20 | 21 | resolve({ 22 | type: 'error', 23 | message: `There is no ${food} available :(`, 24 | }); 25 | }, delay); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/form/__test/form.test.js: -------------------------------------------------------------------------------- 1 | import { includes } from 'lodash/fp'; 2 | import test from 'ava'; 3 | import h from 'react-hyperscript'; 4 | import { shallow } from 'enzyme'; 5 | import { Form } from '../form'; 6 | 7 | test('should have className equal to className prop', (t) => { 8 | const className = 'my-class'; 9 | const component = shallow(h(Form, { 10 | className, 11 | })); 12 | t.true(includes(className, component.prop('className'))); 13 | }); 14 | 15 | test('should have style equal to style prop', (t) => { 16 | const style = { 17 | backgroundColor: 'red', 18 | }; 19 | const component = shallow(h(Form, { 20 | style, 21 | })); 22 | t.deepEqual(component.prop('style'), style); 23 | }); 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | dist 40 | -------------------------------------------------------------------------------- /src/components/form/__test/form.handleFieldChange.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import h from 'react-hyperscript'; 3 | import { shallow } from 'enzyme'; 4 | import sinon from 'sinon'; 5 | import { Form } from '../form'; 6 | 7 | test('should invoke onFieldsChange prop with fields containing updated field', (t) => { 8 | const fields = [ 9 | { id: 'a', value: 'Value 1' }, 10 | { id: 'b', value: 'Value 2' }, 11 | ]; 12 | const onFieldsChange = sinon.spy(); 13 | const component = shallow(h(Form, { 14 | fields, 15 | onFieldsChange, 16 | })); 17 | const updatedField = { id: 'a', value: 'Value 3' }; 18 | const expected = [[ 19 | { id: 'a', value: 'Value 3' }, 20 | { id: 'b', value: 'Value 2' }, 21 | ]]; 22 | component.instance().handleFieldChange(updatedField); 23 | t.deepEqual(onFieldsChange.lastCall.args, expected); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/NestedForm/NestedForm.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import isArray from 'lodash/fp/isArray'; 3 | import React from 'react'; 4 | import h from 'react-hyperscript'; 5 | 6 | export class NestedForm extends React.PureComponent { 7 | static propTypes = { 8 | children: React.PropTypes.node, 9 | field: React.PropTypes.object.isRequired, 10 | }; 11 | 12 | getChildren = () => { 13 | const children = getOr([], 'props.children', this); 14 | 15 | if (!isArray(children)) { 16 | return [children]; 17 | } 18 | 19 | return children; 20 | } 21 | 22 | getLabel = () => 23 | getOr('', 'props.field.label', this); 24 | 25 | render() { 26 | return h('div', { 27 | className: this.props.className, 28 | }, [ 29 | h('label', [ 30 | this.getLabel(), 31 | ]), 32 | ...this.getChildren(), 33 | ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | }, 5 | extends: [ 6 | 'airbnb-base', 7 | 'plugin:import/errors', 8 | 'plugin:import/warnings', 9 | 'plugin:lodash-fp/recommended', 10 | ], 11 | parser: "babel-eslint", 12 | parserOptions: { 13 | ecmaFeatures: { 14 | experimentalObjectRestSpread: true, 15 | }, 16 | }, 17 | plugins: [ 18 | 'lodash-fp', 19 | ], 20 | rules: { 21 | 'import/no-extraneous-dependencies': [ 22 | 'error', 23 | { 24 | devDependencies: [ 25 | '**/*.stories.js', 26 | '**/*.test.js', 27 | '**/webpack-*.config.js', 28 | 'wallaby.js', 29 | ], 30 | }, 31 | ], 32 | 'import/prefer-default-export': 0, 33 | 'linebreak-style': 0, 34 | 'new-cap': 0, 35 | 'no-use-before-define': [ 36 | 'error', 37 | { 38 | classes: true, 39 | functions: false, 40 | }, 41 | ], 42 | 'prefer-const': 2, 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /examples/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | devServer: { 7 | contentBase: __dirname, 8 | historyApiFallback: true, 9 | stats: 'minimal', 10 | }, 11 | entry: { 12 | app: path.join(__dirname, './index.js'), 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.css/, 18 | loader: 'style-loader!css-loader', 19 | exclude: /node_modules/, 20 | }, 21 | { 22 | test: /\.js$/, 23 | loader: 'babel-loader', 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | output: { 29 | chunkFilename: '[id].chunk.js', 30 | filename: 'js/[name].js', 31 | path: __dirname, 32 | publicPath: 'http://localhost:8080/', 33 | }, 34 | plugins: [ 35 | new HtmlWebpackPlugin({ 36 | template: path.join(__dirname, './index.html'), 37 | chunksSortMode: 'dependency', 38 | }), 39 | ], 40 | resolve: { 41 | extensions: ['.js'], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nick Johnson 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 | -------------------------------------------------------------------------------- /src/components/field/__test/field.getComponent.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import h from 'react-hyperscript'; 3 | import { shallow } from 'enzyme'; 4 | import { Field } from '../field'; 5 | 6 | test('should return value of getBuiltInComponent method when field component property is a string', (t) => { 7 | const builtInComponent = () => h('div'); 8 | const getBuiltInComponent = () => builtInComponent; 9 | class TestField extends Field { 10 | getBuiltInComponent = getBuiltInComponent; 11 | } 12 | const component = shallow(h(TestField, { 13 | field: { id: 'a', component: 'text' }, 14 | onFieldChange: () => {}, 15 | })); 16 | const result = component.instance().getComponent(); 17 | t.is(result, builtInComponent); 18 | }); 19 | 20 | test('should return field component property method when field component property is a function', (t) => { 21 | const customComponent = () => h('div'); 22 | const component = shallow(h(Field, { 23 | field: { id: 'a', component: customComponent }, 24 | onFieldChange: () => {}, 25 | })); 26 | const result = component.instance().getComponent(); 27 | t.is(result, customComponent); 28 | }); 29 | -------------------------------------------------------------------------------- /examples/components/RegisterMeal/RegisterMeal.css: -------------------------------------------------------------------------------- 1 | .RegisterMeal { 2 | background-color: white; 3 | display: flex; 4 | flex: 1 1 auto; 5 | flex-direction: column; 6 | padding: 16px; 7 | } 8 | 9 | .RegisterMeal .RegisterMeal__sub-form .RegisterMeal__sub-form__label { 10 | font-size: 1.5em; 11 | margin-bottom: 24px; 12 | } 13 | 14 | .RegisterMeal .RegisterMeal__field { 15 | display: flex; 16 | flex: 0 0 auto; 17 | flex-direction: column; 18 | margin-bottom: 24px; 19 | } 20 | 21 | .RegisterMeal .RegisterMeal__field > label { 22 | margin-bottom: 8px; 23 | } 24 | 25 | .RegisterMeal .RegisterMeal__field > input { 26 | border: 1px solid #999; 27 | border-radius: 4px; 28 | height: 40px; 29 | outline: none; 30 | padding-left: 8px; 31 | padding-right: 8px; 32 | } 33 | 34 | .RegisterMeal .RegisterMeal__field--error > input { 35 | border-color: firebrick; 36 | } 37 | 38 | .RegisterMeal .RegisterMeal__field--success > input { 39 | border-color: limegreen; 40 | } 41 | 42 | .RegisterMeal .RegisterMeal__field__message { 43 | margin-top: 4px; 44 | } 45 | 46 | .RegisterMeal .RegisterMeal__field__message--error { 47 | color: firebrick; 48 | } 49 | 50 | .RegisterMeal .RegisterMeal__field__message--success { 51 | color: limegreen; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/form/__test/form__field.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import h from 'react-hyperscript'; 3 | import { shallow } from 'enzyme'; 4 | import { Form } from '../form'; 5 | 6 | const selector = '.ff-form .ff-form__field'; 7 | 8 | test('should be defined once for each field in fields', (t) => { 9 | const fields = [ 10 | { id: 'a', value: 'Value 1' }, 11 | { id: 'b', value: 'Value 2' }, 12 | ]; 13 | const component = shallow(h(Form, { 14 | fields, 15 | })); 16 | const fieldEls = component.find(selector); 17 | t.deepEqual(fieldEls.length, fields.length); 18 | }); 19 | 20 | test('should have key equal to field id property', (t) => { 21 | const fields = [ 22 | { id: 'a', value: 'Value 1' }, 23 | ]; 24 | const component = shallow(h(Form, { 25 | fields, 26 | })); 27 | const fieldEl = component.find(selector).first(); 28 | t.deepEqual(fieldEl.node.key, fields[0].id); 29 | }); 30 | 31 | test('should have onFieldChange equal to handleFieldChange method', (t) => { 32 | const fields = [ 33 | { id: 'a', value: 'Value 1' }, 34 | ]; 35 | const component = shallow(h(Form, { 36 | fields, 37 | })); 38 | const fieldEl = component.find(selector).first(); 39 | const expected = component.instance().handleFieldChange; 40 | t.is(fieldEl.prop('onFieldChange'), expected); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/field/__test/field.componentDidMount.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import h from 'react-hyperscript'; 3 | import { shallow } from 'enzyme'; 4 | import { Field } from '../field'; 5 | 6 | test('should throw when field id property is nil', (t) => { 7 | const component = shallow(h(Field, { 8 | field: { component: 'text' }, 9 | onFieldChange: () => {}, 10 | })); 11 | const fn = () => component.instance().componentDidMount(); 12 | t.throws(fn); 13 | }); 14 | 15 | test('should throw when getHasValidComponent method returns false', (t) => { 16 | const getHasValidComponent = () => false; 17 | class TestField extends Field { 18 | getHasValidComponent = getHasValidComponent; 19 | } 20 | const component = shallow(h(TestField, { 21 | field: { id: 'a', component: 2 }, 22 | onFieldChange: () => {}, 23 | })); 24 | const fn = () => component.instance().componentDidMount(); 25 | t.throws(fn); 26 | }); 27 | 28 | test('should not throw when field id property is not nil, and getHasValidComponent method returns true', (t) => { 29 | const getHasValidComponent = () => true; 30 | class TestField extends Field { 31 | getHasValidComponent = getHasValidComponent; 32 | } 33 | const component = shallow(h(TestField, { 34 | field: { id: 'a', component: 'text' }, 35 | onFieldChange: () => {}, 36 | })); 37 | const fn = () => component.instance().componentDidMount(); 38 | t.notThrows(fn); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/form/form.js: -------------------------------------------------------------------------------- 1 | import getOr from 'lodash/fp/getOr'; 2 | import map from 'lodash/fp/map'; 3 | import noop from 'lodash/fp/noop'; 4 | import React from 'react'; 5 | import h from 'react-hyperscript'; 6 | import StylePropType from 'react-style-proptype'; 7 | import { Field } from '../field/field'; 8 | 9 | export class Form extends React.PureComponent { 10 | static propTypes = { 11 | className: React.PropTypes.string, 12 | fields: React.PropTypes.object.isRequired, 13 | onFieldsChange: React.PropTypes.func, 14 | style: StylePropType, 15 | }; 16 | 17 | getFields = () => { 18 | const fields = getOr({}, 'props.fields', this); 19 | 20 | return map( 21 | key => ({ 22 | ...getOr({}, key, fields), 23 | key, 24 | }), 25 | Object.getOwnPropertyNames(fields), 26 | ); 27 | } 28 | 29 | handleFieldChange = (field) => { 30 | const fields = getOr({}, 'props.fields', this); 31 | const onFieldsChange = getOr(noop, 'props.onFieldsChange', this); 32 | 33 | onFieldsChange({ 34 | ...fields, 35 | [field.key]: field, 36 | }); 37 | } 38 | 39 | render() { 40 | return h('div', { 41 | className: this.props.className, 42 | style: this.props.style, 43 | }, [ 44 | this.getFields().map(field => h(Field, { 45 | key: field.key, 46 | onFieldChange: this.handleFieldChange, 47 | field, 48 | })), 49 | ]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functional-forms", 3 | "version": "1.0.0-1", 4 | "description": "Dynamic declarative forms for React", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir dist --ignore test.js", 8 | "clean": "rimraf dist", 9 | "lint": "eslint src", 10 | "prebuild": "npm run clean -s", 11 | "prerelease": "npm run lint -s && npm run testonce -s && npm run build -s", 12 | "release": "npm publish", 13 | "start": "webpack-dev-server --hot --inline --config ./examples/webpack.config.js", 14 | "test": "ava --watch", 15 | "testonce": "ava" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/nickjohnson-dev/functional-forms.git" 20 | }, 21 | "keywords": [], 22 | "author": "", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/nickjohnson-dev/functional-forms/issues" 26 | }, 27 | "homepage": "https://github.com/nickjohnson-dev/functional-forms#readme", 28 | "dependencies": { 29 | "axios": "^0.16.2", 30 | "classnames": "^2.2.5", 31 | "lodash": "4.17.4", 32 | "react": "15.4.2", 33 | "react-dom": "15.4.2", 34 | "react-hyperscript": "3.0.0", 35 | "react-style-proptype": "2.0.0" 36 | }, 37 | "devDependencies": { 38 | "ava": "0.18.1", 39 | "babel-cli": "6.22.2", 40 | "babel-core": "6.22.0", 41 | "babel-eslint": "7.1.1", 42 | "babel-loader": "6.2.10", 43 | "babel-plugin-transform-class-properties": "6.22.0", 44 | "babel-preset-es2015": "6.22.0", 45 | "babel-preset-es2017": "6.22.0", 46 | "babel-preset-stage-3": "6.22.0", 47 | "babel-register": "6.22.0", 48 | "css-loader": "^0.28.4", 49 | "enzyme": "2.7.1", 50 | "eslint": "3.15.0", 51 | "eslint-config-airbnb-base": "11.1.0", 52 | "eslint-plugin-import": "2.2.0", 53 | "eslint-plugin-lodash-fp": "2.1.3", 54 | "html-webpack-plugin": "^2.8.1", 55 | "react-addons-test-utils": "15.4.2", 56 | "rimraf": "2.5.1", 57 | "sinon": "2.0.0-pre.5", 58 | "style-loader": "^0.18.2", 59 | "webpack": "2.1.0-beta.25", 60 | "webpack-dev-server": "2.1.0-beta.9" 61 | }, 62 | "ava": { 63 | "babel": "inherit", 64 | "require": [ 65 | "babel-register" 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/components/RegisterMeal/RegisterMeal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import h from 'react-hyperscript'; 3 | import { Form, TextField } from '../../../src/index'; 4 | import { validateAsync } from '../../helpers'; 5 | import './RegisterMeal.css'; 6 | 7 | const Input = props => h(TextField, { 8 | ...props, 9 | className: `RegisterMeal__field RegisterMeal__field--${props.field.key}`, 10 | errorClassName: 'RegisterMeal__field--error', 11 | messageClassName: 'RegisterMeal__field__message', 12 | messageErrorClassName: 'RegisterMeal__field__message--error', 13 | messageSuccessClassName: 'RegisterMeal__field__message--success', 14 | successClassName: 'RegisterMeal__field--success', 15 | }); 16 | 17 | const SubForm = props => h('.RegisterMeal__sub-form', {}, [ 18 | h('.RegisterMeal__sub-form__label', [ 19 | props.field.label, 20 | ]), 21 | h('.RegisterMeal__sub-form__content', props.children), 22 | ]); 23 | 24 | export class RegisterMeal extends React.Component { 25 | constructor(props) { 26 | super(props); 27 | this.state = { 28 | fields: { 29 | name: { 30 | component: Input, 31 | label: 'Name', 32 | getMessages: this.getNameMessages, 33 | value: 'bob', 34 | }, 35 | food: { 36 | component: Input, 37 | label: 'Food', 38 | getMessages: this.getFoodMessages, 39 | value: '', 40 | }, 41 | address: { 42 | component: SubForm, 43 | label: 'Delivery Address', 44 | fields: { 45 | street: { 46 | component: Input, 47 | label: 'Street', 48 | messages: [{ type: 'error', message: 'blah' }], 49 | value: '', 50 | }, 51 | zip: { 52 | component: Input, 53 | label: 'ZIP Code', 54 | value: '', 55 | }, 56 | }, 57 | }, 58 | }, 59 | }; 60 | } 61 | 62 | getFoodMessages = (food) => { 63 | if (!(food.isDirty && food.isTouched)) return undefined; 64 | 65 | if (!food.value) { 66 | return [{ type: 'error', message: 'Please specify a food' }]; 67 | } 68 | 69 | return validateAsync(500, food.value).then(x => [x]); 70 | }; 71 | 72 | getNameMessages = (name) => { 73 | if (!(name.isDirty && name.isTouched)) return undefined; 74 | 75 | if (!name.value) { 76 | return [{ type: 'error', message: 'Please specify a name' }]; 77 | } 78 | 79 | return undefined; 80 | } 81 | 82 | handleFieldsChange = fields => this.setState({ fields }); 83 | 84 | render() { 85 | return h(Form, { 86 | className: 'RegisterMeal', 87 | fields: this.state.fields, 88 | onFieldsChange: this.handleFieldsChange, 89 | }); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/field/field.js: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/fp/debounce'; 2 | import getOr from 'lodash/fp/getOr'; 3 | import isArray from 'lodash/fp/isArray'; 4 | import isEqual from 'lodash/fp/isEqual'; 5 | import map from 'lodash/fp/map'; 6 | import noop from 'lodash/fp/noop'; 7 | import omit from 'lodash/fp/omit'; 8 | import React from 'react'; 9 | import h from 'react-hyperscript'; 10 | import { makeCancelable } from '../../helpers'; 11 | import { NestedForm } from '../NestedForm/NestedForm'; 12 | 13 | export class Field extends React.PureComponent { 14 | static propTypes = { 15 | field: React.PropTypes.object.isRequired, 16 | onFieldChange: React.PropTypes.func.isRequired, 17 | }; 18 | 19 | messagesPromise; 20 | 21 | componentWillReceiveProps(nextProps) { 22 | const fieldHasChanged = !isEqual( 23 | omit(['messages'], this.props.field), 24 | omit(['messages'], nextProps.field), 25 | ); 26 | 27 | if (fieldHasChanged) { 28 | this.updateMessages(nextProps.field); 29 | } 30 | } 31 | 32 | getComponent = () => 33 | getOr(NestedForm, 'props.field.component', this); 34 | 35 | getFields = () => { 36 | const fields = getOr({}, 'props.field.fields', this); 37 | 38 | return map( 39 | key => ({ 40 | ...getOr({}, key, fields), 41 | key, 42 | }), 43 | Object.getOwnPropertyNames(fields), 44 | ); 45 | } 46 | 47 | handleChange = (value) => { 48 | const field = getOr({}, 'props.field', this); 49 | const onFieldChange = getOr(noop, 'props.onFieldChange', this); 50 | 51 | onFieldChange({ 52 | ...field, 53 | isDirty: true, 54 | value, 55 | }); 56 | 57 | this.setState({ 58 | messages: [], 59 | }); 60 | }; 61 | 62 | handleSubfieldChange = (subField) => { 63 | const field = getOr({}, 'props.field', this); 64 | const fields = getOr({}, 'fields', field); 65 | const onFieldChange = getOr(noop, 'props.onFieldChange', this); 66 | 67 | onFieldChange({ 68 | ...field, 69 | fields: { 70 | ...fields, 71 | [subField.key]: subField, 72 | }, 73 | }); 74 | } 75 | 76 | handleIsTouchedChange = (isTouched) => { 77 | this.props.onFieldChange({ 78 | ...this.props.field, 79 | isTouched, 80 | }); 81 | }; 82 | 83 | handleMessagesChange = debounce(250, (messages) => { 84 | const field = getOr({}, 'props.field', this); 85 | const onFieldChange = getOr(noop, 'props.onFieldChange', this); 86 | 87 | onFieldChange({ 88 | ...field, 89 | messages, 90 | }); 91 | }); 92 | 93 | updateMessages = (field) => { 94 | this.messagesPromise = makeCancelable(new Promise((resolve) => { 95 | if (this.messagesPromise) this.messagesPromise.cancel(); 96 | 97 | const getMessages = getOr(() => [], 'getMessages', field); 98 | const messages = getMessages(field); 99 | 100 | if (messages instanceof Promise) { 101 | messages.then(resolve); 102 | return; 103 | } 104 | 105 | if (isArray(messages)) { 106 | resolve(messages); 107 | } 108 | 109 | resolve([]); 110 | })); 111 | 112 | this.messagesPromise.promise 113 | .then(this.handleMessagesChange) 114 | .catch(() => {}); 115 | } 116 | 117 | render() { 118 | return h(this.getComponent(), { 119 | field: this.props.field, 120 | onChange: this.handleChange, 121 | onIsTouchedChange: this.handleIsTouchedChange, 122 | }, [ 123 | ...this.getFields().map(field => h(Field, { 124 | key: field.key, 125 | onFieldChange: this.handleSubfieldChange, 126 | field, 127 | })), 128 | ]); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/components/text-field/text-field.js: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import getOr from 'lodash/fp/getOr'; 3 | import isEmpty from 'lodash/fp/isEmpty'; 4 | import noop from 'lodash/fp/noop'; 5 | import some from 'lodash/fp/some'; 6 | import React from 'react'; 7 | import h from 'react-hyperscript'; 8 | 9 | export class TextField extends React.PureComponent { 10 | static propTypes = { 11 | className: React.PropTypes.string, 12 | errorClassName: React.PropTypes.string, 13 | field: React.PropTypes.object, 14 | messageClassName: React.PropTypes.string, 15 | messageErrorClassName: React.PropTypes.string, 16 | messageSuccessClassName: React.PropTypes.string, 17 | onChange: React.PropTypes.func, 18 | onIsTouchedChange: React.PropTypes.func, 19 | successClassName: React.PropTypes.string, 20 | }; 21 | 22 | getClassName = () => { 23 | const errorClassName = getOr('', 'props.errorClassName', this); 24 | const successClassName = getOr('', 'props.successClassName', this); 25 | const messages = this.getMessages(); 26 | const hasMessages = !isEmpty(messages); 27 | const hasError = some(m => m.type === 'error', messages); 28 | 29 | return classnames({ 30 | [errorClassName]: hasMessages && hasError, 31 | [successClassName]: hasMessages && !hasError, 32 | }, this.props.className); 33 | } 34 | 35 | getMessageClassName = (message) => { 36 | const messageClassName = getOr('', 'props.messageClassName', this); 37 | const messageErrorClassName = getOr('', 'props.messageErrorClassName', this); 38 | const messageSuccessClassName = getOr('', 'props.messageSuccessClassName', this); 39 | 40 | return classnames({ 41 | [messageErrorClassName]: message.type === 'error', 42 | [messageSuccessClassName]: message.type === 'success', 43 | }, messageClassName); 44 | } 45 | 46 | getLabel = () => 47 | getOr('', 'props.field.label', this); 48 | 49 | getMessages = () => 50 | getOr([], 'props.field.messages', this); 51 | 52 | getValue = () => 53 | getOr('', 'props.field.value', this); 54 | 55 | handleInputBlur = (e) => { 56 | const isTouched = getOr(false, 'props.isTouched', this); 57 | const onBlur = getOr(noop, 'props.onBlur', this); 58 | const onIsTouchedChange = getOr(noop, 'props.onIsTouchedChange', this); 59 | 60 | if (!isTouched) { 61 | onIsTouchedChange(true); 62 | } 63 | 64 | onBlur(e); 65 | } 66 | 67 | handleInputChange = (e) => { 68 | const onChange = getOr(noop, 'props.onChange', this); 69 | const fieldOnChange = getOr(noop, 'props.field.onChange', this); 70 | const value = getOr('', 'target.value', e); 71 | 72 | onChange(value); 73 | fieldOnChange(e); 74 | } 75 | 76 | handleInputClick = (e) => { 77 | const onClick = getOr(noop, 'props.field.onClick', this); 78 | const field = getOr({}, 'props.field', e); 79 | 80 | onClick(e, field); 81 | } 82 | 83 | handleInputFocus = (e) => { 84 | const onFocus = getOr(noop, 'props.field.onFocus', this); 85 | const field = getOr({}, 'props.field', e); 86 | 87 | onFocus(e, field); 88 | } 89 | 90 | handleInputKeyDown = (e) => { 91 | const onKeyDown = getOr(noop, 'props.field.onKeyDown', this); 92 | const field = getOr({}, 'props.field', e); 93 | 94 | onKeyDown(e, field); 95 | } 96 | 97 | handleInputKeyPress = (e) => { 98 | const onKeyPress = getOr(noop, 'props.field.onKeyPress', this); 99 | const field = getOr({}, 'props.field', e); 100 | 101 | onKeyPress(e, field); 102 | } 103 | 104 | handleInputKeyUp = (e) => { 105 | const onKeyUp = getOr(noop, 'props.field.onKeyUp', this); 106 | const field = getOr({}, 'props.field', e); 107 | 108 | onKeyUp(e, field); 109 | } 110 | 111 | handleInputMouseDown = (e) => { 112 | const onMouseDown = getOr(noop, 'props.field.onMouseDown', this); 113 | const field = getOr({}, 'props.field', e); 114 | 115 | onMouseDown(e, field); 116 | } 117 | 118 | handleInputMouseUp = (e) => { 119 | const onMouseUp = getOr(noop, 'props.field.onMouseUp', this); 120 | const field = getOr({}, 'props.field', e); 121 | 122 | onMouseUp(e, field); 123 | } 124 | 125 | render() { 126 | return h('div', { 127 | className: this.getClassName(), 128 | }, [ 129 | h('label', [ 130 | this.getLabel(), 131 | ]), 132 | h('input', { 133 | onBlur: this.handleInputBlur, 134 | onChange: this.handleInputChange, 135 | onClick: this.handleInputClick, 136 | onFocus: this.handleInputFocus, 137 | onKeyDown: this.handleKeyDown, 138 | onKeyPress: this.handleKeyPress, 139 | onKeyUp: this.handleKeyUp, 140 | onMouseDown: this.handleMouseDown, 141 | onMouseUp: this.handleMouseUp, 142 | type: 'text', 143 | value: this.getValue(), 144 | }), 145 | this.getMessages().map((status, index) => h('div', { 146 | className: this.getMessageClassName(status), 147 | key: index, 148 | }, [ 149 | status.message, 150 | ])), 151 | ]); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/components/field/__test/field.test.js: -------------------------------------------------------------------------------- 1 | import { includes } from 'lodash/fp'; 2 | import test from 'ava'; 3 | import h from 'react-hyperscript'; 4 | import { shallow } from 'enzyme'; 5 | import { Field } from '../field'; 6 | 7 | test('should be defined', (t) => { 8 | const component = shallow(h(Field, { 9 | field: { id: 'a', component: 'text' }, 10 | onFieldChange: () => {}, 11 | })); 12 | t.is(component.length, 1); 13 | }); 14 | 15 | test('should have name equal to return value of getComponent method', (t) => { 16 | const comp = () => h('div', {}); 17 | const getComponent = () => comp; 18 | class TestField extends Field { 19 | getComponent = getComponent; 20 | } 21 | const component = shallow(h(TestField, { 22 | field: { id: 'a', component: 'text' }, 23 | onFieldChange: () => {}, 24 | })); 25 | t.true(component.is(comp)); 26 | }); 27 | 28 | test('should have className equal to className prop', (t) => { 29 | const className = 'my-class'; 30 | const component = shallow(h(Field, { 31 | field: { id: 'a', component: 'text' }, 32 | onFieldChange: () => {}, 33 | className, 34 | })); 35 | t.true(includes(className, component.prop('className'))); 36 | }); 37 | 38 | test('should have errors equal to return value of getErrors method', (t) => { 39 | const errors = ['An error']; 40 | const getErrors = () => errors; 41 | class TestField extends Field { 42 | getErrors = getErrors; 43 | } 44 | const component = shallow(h(TestField, { 45 | field: { id: 'a', component: 'text' }, 46 | onFieldChange: () => {}, 47 | errors, 48 | })); 49 | t.deepEqual(component.prop('errors'), errors); 50 | }); 51 | 52 | test('should have isTouched equal to field isTouched property', (t) => { 53 | const isTouched = true; 54 | const component = shallow(h(Field, { 55 | field: { id: 'a', component: 'text', isTouched }, 56 | onFieldChange: () => {}, 57 | })); 58 | t.deepEqual(component.prop('isTouched'), isTouched); 59 | }); 60 | 61 | test('should have label equal to return value of getLabel method', (t) => { 62 | const label = 'A Label'; 63 | const getLabel = () => label; 64 | class TestField extends Field { 65 | getLabel = getLabel; 66 | } 67 | const component = shallow(h(TestField, { 68 | field: { id: 'a', component: 'text' }, 69 | onFieldChange: () => {}, 70 | label, 71 | })); 72 | t.deepEqual(component.prop('label'), label); 73 | }); 74 | 75 | test('should have onBlur equal to handleBlur method', (t) => { 76 | const component = shallow(h(Field, { 77 | field: { id: 'a', component: 'text' }, 78 | onFieldChange: () => {}, 79 | })); 80 | const expected = component.instance().handleBlur; 81 | t.deepEqual(component.prop('onBlur'), expected); 82 | }); 83 | 84 | test('should have onChange equal to handleChange method', (t) => { 85 | const component = shallow(h(Field, { 86 | field: { id: 'a', component: 'text' }, 87 | onFieldChange: () => {}, 88 | })); 89 | const expected = component.instance().handleChange; 90 | t.deepEqual(component.prop('onChange'), expected); 91 | }); 92 | 93 | test('should have onClick equal to handleClick method', (t) => { 94 | const component = shallow(h(Field, { 95 | field: { id: 'a', component: 'text' }, 96 | onFieldChange: () => {}, 97 | })); 98 | const expected = component.instance().handleClick; 99 | t.deepEqual(component.prop('onClick'), expected); 100 | }); 101 | 102 | test('should have onFocus equal to handleFocus method', (t) => { 103 | const component = shallow(h(Field, { 104 | field: { id: 'a', component: 'text' }, 105 | onFieldChange: () => {}, 106 | })); 107 | const expected = component.instance().handleFocus; 108 | t.deepEqual(component.prop('onFocus'), expected); 109 | }); 110 | 111 | test('should have onIsTouchedChange equal to handleIsTouchedChange method', (t) => { 112 | const component = shallow(h(Field, { 113 | field: { id: 'a', component: 'text' }, 114 | onFieldChange: () => {}, 115 | })); 116 | const expected = component.instance().handleIsTouchedChange; 117 | t.deepEqual(component.prop('onIsTouchedChange'), expected); 118 | }); 119 | 120 | test('should have onKeyDown equal to handleKeyDown method', (t) => { 121 | const component = shallow(h(Field, { 122 | field: { id: 'a', component: 'text' }, 123 | onFieldChange: () => {}, 124 | })); 125 | const expected = component.instance().handleKeyDown; 126 | t.deepEqual(component.prop('onKeyDown'), expected); 127 | }); 128 | 129 | test('should have onKeyPress equal to handleKeyPress method', (t) => { 130 | const component = shallow(h(Field, { 131 | field: { id: 'a', component: 'text' }, 132 | onFieldChange: () => {}, 133 | })); 134 | const expected = component.instance().handleKeyPress; 135 | t.deepEqual(component.prop('onKeyPress'), expected); 136 | }); 137 | 138 | test('should have onKeyUp equal to handleKeyUp method', (t) => { 139 | const component = shallow(h(Field, { 140 | field: { id: 'a', component: 'text' }, 141 | onFieldChange: () => {}, 142 | })); 143 | const expected = component.instance().handleKeyUp; 144 | t.deepEqual(component.prop('onKeyUp'), expected); 145 | }); 146 | 147 | test('should have onMouseDown equal to handleMouseDown method', (t) => { 148 | const component = shallow(h(Field, { 149 | field: { id: 'a', component: 'text' }, 150 | onFieldChange: () => {}, 151 | })); 152 | const expected = component.instance().handleMouseDown; 153 | t.deepEqual(component.prop('onMouseDown'), expected); 154 | }); 155 | 156 | test('should have onMouseUp equal to handleMouseUp method', (t) => { 157 | const component = shallow(h(Field, { 158 | field: { id: 'a', component: 'text' }, 159 | onFieldChange: () => {}, 160 | })); 161 | const expected = component.instance().handleMouseUp; 162 | t.deepEqual(component.prop('onMouseUp'), expected); 163 | }); 164 | 165 | test('should have value equal to field value property', (t) => { 166 | const value = 'Some Value'; 167 | const component = shallow(h(Field, { 168 | field: { id: 'a', component: 'text', value }, 169 | onFieldChange: () => {}, 170 | })); 171 | t.deepEqual(component.prop('value'), value); 172 | }); 173 | --------------------------------------------------------------------------------