├── 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 |
--------------------------------------------------------------------------------