├── .gitattributes
├── examples
├── .gitignore
├── src
│ ├── client
│ │ ├── components
│ │ │ ├── StateAsText
│ │ │ │ ├── index.js
│ │ │ │ ├── View.jsx
│ │ │ │ └── Container.js
│ │ │ └── ExampleDescription
│ │ │ │ └── ExampleDescription.jsx
│ │ ├── postcss.config.js
│ │ ├── config
│ │ │ ├── paths.js
│ │ │ ├── prod.webpack.config.js
│ │ │ └── dev.webpack.config.js
│ │ ├── Application
│ │ │ ├── Application.scss
│ │ │ ├── index.html
│ │ │ ├── index.jsx
│ │ │ └── Root.jsx
│ │ ├── reducers
│ │ │ ├── customReducerActions.js
│ │ │ ├── asyncValidation.js
│ │ │ └── index.js
│ │ └── routes
│ │ │ ├── DefaultState.jsx
│ │ │ ├── Basic.jsx
│ │ │ ├── InterceptOnChange.jsx
│ │ │ ├── OnStateChange.jsx
│ │ │ ├── Nested.jsx
│ │ │ ├── CustomReducerActions.jsx
│ │ │ ├── InputTypes.jsx
│ │ │ └── AsyncValidation.jsx
│ └── server
│ │ └── index.js
├── README.md
└── package.json
├── .gitignore
├── circle.yml
├── .npmignore
├── src
├── Input
│ ├── components
│ │ ├── TextArea.jsx
│ │ ├── Select.jsx
│ │ └── Input.jsx
│ ├── containers
│ │ ├── InputContainer.proptypes.js
│ │ └── InputContainer.js
│ └── ducks
│ │ └── Input.js
└── index.js
├── .babelrc
├── test
├── util
│ └── fakes
│ │ └── ConfiguredProvider.js
├── Input
│ ├── components
│ │ ├── TextArea.test.js
│ │ ├── Input.test.js
│ │ └── Select.test.js
│ ├── containers
│ │ ├── InputContainer.proptypes.test.js
│ │ └── InputContainer.test.js
│ └── ducks
│ │ └── Input.test.js
└── index.test.js
├── webpack.config.js
├── package.json
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
--------------------------------------------------------------------------------
/examples/.gitignore:
--------------------------------------------------------------------------------
1 | build
--------------------------------------------------------------------------------
/examples/src/client/components/StateAsText/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Container';
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | *.log
3 | .idea
4 | node_modules
5 | dist
6 | coverage
7 | /package-lock.json
8 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | machine:
2 | node:
3 | version: 7.7.4
4 | test:
5 | override:
6 | - npm run test:ci
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | *.log
3 | .idea
4 | coverage
5 | examples
6 | src
7 | test
8 | .babelrc
9 | .gitattributes
10 | .gitignore
11 | circle.yml
12 | .npmignore
13 | /package-lock.json
14 | webpack.config.js
15 |
--------------------------------------------------------------------------------
/src/Input/components/TextArea.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | const TextArea = props =>
5 | ;
6 |
7 | TextArea.propTypes = {
8 | name: PropTypes.string.isRequired,
9 | };
10 |
11 | export default TextArea;
--------------------------------------------------------------------------------
/examples/src/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {
4 | browsers: [
5 | ">1%",
6 | "last 4 versions",
7 | "Firefox ESR",
8 | "not ie < 9",
9 | ]
10 | },
11 | },
12 | };
--------------------------------------------------------------------------------
/src/Input/components/Select.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | const Select = ({children, ...props}) =>
5 |
6 | {children}
7 | ;
8 |
9 | Select.propTypes = {
10 | name: PropTypes.string.isRequired,
11 | };
12 |
13 | export default Select;
--------------------------------------------------------------------------------
/src/Input/components/Input.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | const Input = props =>
5 | ;
6 |
7 | Input.propTypes = {
8 | name: PropTypes.string.isRequired,
9 | };
10 |
11 | Input.defaultProps = {
12 | type: 'text',
13 | };
14 |
15 | export default Input;
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "transform-object-rest-spread"
4 | ],
5 | "env": {
6 | "es": {
7 | "presets": [
8 | ["es2015", {"modules": false}],
9 | ["react"]
10 | ]
11 | },
12 | "umd": {
13 | "presets": ["es2015", "react"]
14 | },
15 | "test": {
16 | "presets": ["es2015", "react"]
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | Examples
2 | =
3 | > Examples to demonstrate light-form usage
4 |
5 | To run this example, perform the following steps:
6 |
7 | * Clone the repository
8 | * `cd` to this folder
9 | * `npm install`
10 | * `npm start`
11 | * Open [http://localhost:3000](http://localhost:3000)
12 | * Open [Redux DevTools](https://github.com/gaearon/redux-devtools)
13 | * Explore the examples, resulting state shape and updates
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import InputContainer from "./Input/containers/InputContainer";
2 | import InputComponent from "./Input/components/Input";
3 | import TextAreaComponent from "./Input/components/TextArea";
4 | import SelectComponent from "./Input/components/Select";
5 | export {default as Reducer} from "./Input/ducks/Input";
6 |
7 | export const Input = InputContainer(InputComponent);
8 | export const TextArea = InputContainer(TextAreaComponent);
9 | export const Select = InputContainer(SelectComponent);
--------------------------------------------------------------------------------
/examples/src/client/components/StateAsText/View.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import JSONPretty from 'react-json-pretty';
4 |
5 | const StateAsText = ({ state, nodeName }) =>
6 | (
7 |
state.{nodeName}
8 |
9 | );
10 |
11 | StateAsText.propTypes = {
12 | state: PropTypes.object.isRequired, // eslint-disable-line
13 | nodeName: PropTypes.string.isRequired,
14 | };
15 |
16 | export default StateAsText;
17 |
--------------------------------------------------------------------------------
/examples/src/client/components/StateAsText/Container.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { connect } from 'react-redux';
3 | import View from './View';
4 |
5 | const Container = connect(
6 | state => ({ state }),
7 | null,
8 | (state, dispatch, ownProps) => ({
9 | state: state.state[ownProps.nodeName],
10 | nodeName: ownProps.nodeName,
11 | }),
12 | )(View);
13 |
14 | Container.propTypes = {
15 | nodeName: PropTypes.string.isRequired,
16 | };
17 |
18 | export default Container;
19 |
--------------------------------------------------------------------------------
/examples/src/client/config/paths.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const applicationFolder = path.resolve(__dirname, '..', '..', 'client', 'Application');
4 | const srcDir = path.resolve(__dirname, '..', '..');
5 |
6 | module.exports = {
7 | buildPath: path.join(__dirname, '..', '..', '..', 'build'),
8 | entry: path.join(applicationFolder, 'index.jsx'),
9 | src: srcDir,
10 | publicAssets: path.join(srcDir, 'client', 'public'),
11 | htmlPluginTemplate: path.join(applicationFolder, 'index.html'),
12 | favIcon: path.join(applicationFolder, 'favicon.ico'),
13 | };
14 |
--------------------------------------------------------------------------------
/examples/src/client/Application/Application.scss:
--------------------------------------------------------------------------------
1 |
2 | $color-blossom: #333;
3 | $color-fade: blue;
4 |
5 | $color-bg: #f9f9f9;
6 | $color-bg-alt: #f1f1f1;
7 |
8 | $color-text: #4a4a4a;
9 | $font-size-base: 1.8rem;
10 |
11 | @import "~sakura.css/scss/_main.scss";
12 |
13 | input:not([type="radio"]) {
14 | padding: 5px;
15 | margin: 10px;
16 | }
17 |
18 | label {
19 | display: inline;
20 | }
21 |
22 | h1 {
23 | font-size: 22px;
24 | }
25 |
26 | .valid {
27 | background-color: lightgreen !important;
28 | }
29 |
30 | .invalid {
31 | background-color: lightpink !important;
32 | }
33 |
34 | .hint {
35 | font-size: 14px;
36 | }
--------------------------------------------------------------------------------
/examples/src/client/Application/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Light-form simple ex boilerplate
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/examples/src/client/components/ExampleDescription/ExampleDescription.jsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class ExampleDescription extends PureComponent {
5 | render() {
6 | return (
7 |
8 |
9 | examples/src/client/routes/{this.props.route}.jsx
10 |
11 |
{this.props.description}
12 |
13 |
14 | );
15 | }
16 | }
17 |
18 | ExampleDescription.propTypes = {
19 | route: PropTypes.string.isRequired,
20 | description: PropTypes.string.isRequired,
21 | };
22 |
23 | export default ExampleDescription;
24 |
--------------------------------------------------------------------------------
/examples/src/client/reducers/customReducerActions.js:
--------------------------------------------------------------------------------
1 | import reducer from '../../../../src/Input/ducks/Input';
2 |
3 | const RESET_FORM = 'RESET_FORM';
4 | const MULTIPLY_VALUES = 'MULTIPLY_VALUES';
5 |
6 | export const resetForm = () => ({ type: RESET_FORM });
7 | export const multiplyValues = () => ({ type: MULTIPLY_VALUES });
8 |
9 | const resetActionHandler = {
10 | [RESET_FORM]: () => ({}),
11 | [MULTIPLY_VALUES]: (state) => {
12 | const stateCopy = { ...state };
13 | Object.keys(stateCopy).forEach((key) => {
14 | stateCopy[key] *= 2;
15 | });
16 |
17 | return stateCopy;
18 | },
19 | };
20 |
21 | export default reducer('customReducerActions', {}, null, resetActionHandler);
22 |
--------------------------------------------------------------------------------
/examples/src/client/reducers/asyncValidation.js:
--------------------------------------------------------------------------------
1 | const VALIDATE_STARTED = 'VALIDATE_STARTED';
2 | const VALIDATE_COMPLETED = 'VALIDATE_COMPLETED';
3 |
4 | export const asyncValidationAction = (value, dispatch) => {
5 | dispatch(({ type: VALIDATE_STARTED }));
6 | setTimeout(() => dispatch({ type: VALIDATE_COMPLETED, payload: value }), 1000);
7 | };
8 |
9 | export default (state = {}, action) => {
10 | switch (action.type) {
11 | case VALIDATE_STARTED:
12 | return ({ ...state, waitngForAsyncResponse: true });
13 |
14 | case VALIDATE_COMPLETED:
15 | return ({
16 | ...state,
17 | waitngForAsyncResponse: false,
18 | field1Invalid: Boolean(Number(action.payload)),
19 | });
20 |
21 | default:
22 | return state;
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/examples/src/client/routes/DefaultState.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Input } from '../../../../src';
3 | import StateAsText from '../components/StateAsText';
4 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
5 |
6 | export default () =>
7 | (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
);
15 |
--------------------------------------------------------------------------------
/test/util/fakes/ConfiguredProvider.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import {createStore, combineReducers} from "redux";
4 | import {Provider} from "react-redux";
5 | import reducer from "../../../src/Input/ducks/Input";
6 |
7 | const reducers = combineReducers({
8 | test: reducer('test'),
9 | });
10 |
11 | export const generateStore = () =>
12 | (createStore(reducers));
13 |
14 | const ConfiguredProvider = ({children, customStore}) =>
15 | ;
16 |
17 | ConfiguredProvider.propTypes = {
18 | children: PropTypes.node.isRequired,
19 | customStore: PropTypes.object,
20 | };
21 |
22 | ConfiguredProvider.defaultProps = {
23 | customStore: undefined,
24 | };
25 |
26 | export default ConfiguredProvider;
--------------------------------------------------------------------------------
/examples/src/client/routes/Basic.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Input } from '../../../../src';
3 | import StateAsText from '../components/StateAsText';
4 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
5 |
6 | export default () =>
7 | (
8 |
9 |
10 | First name
11 |
12 |
13 |
14 |
15 | Middle name
16 |
17 |
18 |
19 |
20 | Last name
21 |
22 |
23 |
24 |
25 |
);
26 |
--------------------------------------------------------------------------------
/examples/src/client/Application/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render as renderToDOM } from 'react-dom';
3 | import { AppContainer } from 'react-hot-loader';
4 | import { createStore } from 'redux';
5 | import { Provider } from 'react-redux';
6 | import Root from './Root';
7 | import rootReducer from '../reducers';
8 |
9 | const store = createStore(rootReducer, window.devToolsExtension && window.devToolsExtension());
10 |
11 | const render = Component =>
12 | renderToDOM(
13 |
14 |
15 |
16 |
17 | ,
18 | document.getElementById('root'),
19 | );
20 |
21 | render(Root);
22 |
23 | if (module.hot) {
24 | module.hot.accept('./Root', () => render(require('./Root').default)); // eslint-disable-line
25 | module.hot.accept('../reducers', () => store.replaceReducer(require('../reducers/index').default)); // eslint-disable-line
26 | }
27 |
--------------------------------------------------------------------------------
/test/Input/components/TextArea.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TextArea from '../../../src/Input/components/TextArea';
3 | import {mount} from 'enzyme';
4 |
5 | describe('TextArea component', () => {
6 | it('can be found through class and id selector when mounted', () => {
7 | const input = mount();
8 | const mountedComponents = [input.find('.textAreaField'), input.find('#myTextArea')];
9 |
10 | expect(mountedComponents.length).toBe(2);
11 | });
12 |
13 | it('has the passed props as attributes', () => {
14 | const container = mount();
15 | const input = container.find('#myTextArea');
16 |
17 | expect(input.prop('disabled')).toEqual(true);
18 | expect(input.prop('alt')).toEqual('this is my textarea');
19 | expect(input.prop('thisdoesnotexist')).toBeUndefined();
20 | });
21 | });
22 |
23 |
--------------------------------------------------------------------------------
/examples/src/client/routes/InterceptOnChange.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import currencyFormatter from 'currency-formatter';
3 | import { Input } from '../../../../src';
4 | import StateAsText from '../components/StateAsText';
5 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
6 |
7 | const formatCurrency = (event) => {
8 | const eventCopy = { ...event };
9 | eventCopy.target.value = currencyFormatter.format(eventCopy.target.value, { locale: 'en-US', precision: 0 });
10 | return eventCopy;
11 | };
12 |
13 | export default () =>
14 | (
15 |
19 |
25 |
26 |
);
27 |
--------------------------------------------------------------------------------
/src/Input/containers/InputContainer.proptypes.js:
--------------------------------------------------------------------------------
1 | const errorMessage = (propName, componentName, customMessage) =>
2 | (`Invalid property '${propName}' supplied to '${componentName}'. ${customMessage}`);
3 |
4 | const validateNameProp = (props, propName, componentName) => {
5 | if (typeof props[propName] !== 'string') {
6 | return new Error(errorMessage(propName, componentName, 'Value must be a valid string.'));
7 | }
8 |
9 | if (!/.+\..+/.test(props[propName])) {
10 | return new Error(errorMessage(
11 | propName,
12 | componentName,
13 | 'Value must contain a dot-delimited namespace. (eg. name="customer.firstname")'
14 | ));
15 | }
16 | };
17 |
18 | const validateOnChangeProp = (props, propName, componentName) => {
19 | if (typeof props[propName] !== 'function') {
20 | return new Error(errorMessage(propName, componentName, 'Value must be a function.'));
21 | }
22 | };
23 |
24 | export default {
25 | name: validateNameProp,
26 | onChange: validateOnChangeProp,
27 | }
28 |
--------------------------------------------------------------------------------
/examples/src/client/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { validate } from 'email-validator';
3 | import reducer from '../../../../src/Input/ducks/Input';
4 | import customReducerActions from './customReducerActions';
5 | import asyncValidation from './asyncValidation';
6 |
7 | const defaultState = {
8 | firstname: 'Jonas',
9 | lastname: 'Jensen',
10 | };
11 |
12 | const asyncDefaultState = {
13 | field1: 'default',
14 | field2: 4,
15 | field3: 10,
16 | };
17 |
18 | const onStateChange = state =>
19 | ({
20 | ...state,
21 | valid: validate(state.email),
22 | });
23 |
24 | export default combineReducers({
25 | basic: reducer('basic'),
26 | inputTypes: reducer('inputTypes'),
27 | nested: reducer('nested'),
28 | defaultState: reducer('defaultState', defaultState),
29 | customReducerActions,
30 | interceptOnChange: reducer('interceptOnChange'),
31 | onStateChange: reducer('onStateChange', {}, onStateChange),
32 | async: reducer('async', asyncDefaultState),
33 | asyncValidation,
34 | // .. other reducers
35 | });
36 |
--------------------------------------------------------------------------------
/src/Input/ducks/Input.js:
--------------------------------------------------------------------------------
1 | import dotProp from "dot-prop-immutable";
2 |
3 | const UPDATE_INPUT_VALUE = 'UPDATE_INPUT_VALUE';
4 |
5 | export const changeField = (type, name, value) => ({
6 | type,
7 | name,
8 | value,
9 | });
10 |
11 | export const createBoundType = namespace =>
12 | (UPDATE_INPUT_VALUE + '.' + namespace);
13 |
14 | export default (namespace, defaultState, onStateChange, actionHandlers) =>
15 | (state = defaultState || {}, action) => {
16 | const boundType = createBoundType(namespace);
17 |
18 | const reducer = {
19 | [boundType]: () => {
20 | const fieldPathWithoutNamespace = action.name.replace(namespace + '.', '');
21 | const newState = dotProp.set(state, fieldPathWithoutNamespace, action.value);
22 | return onStateChange && onStateChange(newState) || newState;
23 | },
24 |
25 | ...actionHandlers,
26 | };
27 |
28 | return reducer[action.type]
29 | ? reducer[action.type](state, action)
30 | : state;
31 | };
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const webpack = require("webpack");
3 |
4 | const srcDir = __dirname;
5 |
6 | module.exports = {
7 | entry: path.join(srcDir, 'src', 'index.js'),
8 | output: {
9 | path: path.join(srcDir, 'dist'),
10 | filename: 'light-form.min.js',
11 | libraryTarget: "umd",
12 | library: "light-form",
13 | },
14 | externals: {
15 | "react": "React",
16 | "react-dom": "ReactDOM",
17 | "react-redux": "ReactRedux",
18 | "redux": "Redux",
19 | },
20 | resolve: {
21 | extensions: ['.js', '.jsx'],
22 | },
23 | module: {
24 | rules: [
25 | {
26 | test: /\.jsx?$/,
27 | include: path.join(srcDir, 'src'),
28 | loader: 'babel-loader',
29 | options: {
30 | presets: [["es2015", {modules: false}], ["react"]],
31 | plugins: [["transform-react-remove-prop-types", {removeImport: true}]]
32 | }
33 | },
34 | ],
35 | },
36 | plugins: [
37 | new webpack.DefinePlugin({'process.env.NODE_ENV': '"production"'}),
38 | new webpack.optimize.UglifyJsPlugin(),
39 | ],
40 | };
--------------------------------------------------------------------------------
/examples/src/client/routes/OnStateChange.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Input } from '../../../../src';
5 | import StateAsText from '../components/StateAsText';
6 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
7 |
8 | const emailValidationForm = ({ valid }) =>
9 | (
10 |
14 |
20 |
21 |
);
22 |
23 | emailValidationForm.propTypes = {
24 | valid: PropTypes.bool,
25 | };
26 |
27 | emailValidationForm.defaultProps = {
28 | valid: false,
29 | };
30 |
31 |
32 | export default connect(state => ({ valid: state.onStateChange.valid }))(emailValidationForm);
33 |
--------------------------------------------------------------------------------
/examples/src/client/routes/Nested.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Input } from '../../../../src';
3 | import StateAsText from '../components/StateAsText';
4 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
5 |
6 | export default () =>
7 | (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
);
19 |
--------------------------------------------------------------------------------
/test/Input/components/Input.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Input from '../../../src/Input/components/Input';
3 | import {shallow, mount} from 'enzyme';
4 |
5 | describe('Input component', () => {
6 | it('renders with type=text as default when no type is specified', () => {
7 | const input = shallow( );
8 |
9 | expect(input.props().type).toBe('text');
10 | });
11 |
12 | it('can be found through class and id selector when mounted', () => {
13 | const input = mount( );
14 | const mountedComponents = [input.find('.inputField'), input.find('#myInput')];
15 |
16 | expect(mountedComponents.length).toBe(2);
17 | });
18 |
19 | it('has the passed props as attributes', () => {
20 | const container = mount( );
21 | const input = container.find('#myInput');
22 |
23 | expect(input.prop('disabled')).toEqual(true);
24 | expect(input.prop('alt')).toEqual('this is my input');
25 | expect(input.prop('thisdoesnotexist')).toBeUndefined();
26 | });
27 | });
28 |
29 |
--------------------------------------------------------------------------------
/test/Input/components/Select.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Select from '../../../src/Input/components/Select';
3 | import {shallow, mount} from 'enzyme';
4 |
5 | describe('Select component', () => {
6 | it('accepts children', () => {
7 | const select = shallow(
8 |
9 | text
10 |
11 | );
12 |
13 | expect(select.props().children).toEqual(text );
14 | });
15 |
16 | it('can be found through class and id selector when mounted', () => {
17 | const input = mount( );
18 | const mountedComponents = [input.find('.selectField'), input.find('#mySelect')];
19 |
20 | expect(mountedComponents.length).toBe(2);
21 | });
22 |
23 | it('has the passed props as attributes', () => {
24 | const container = mount( );
25 | const input = container.find('#mySelect');
26 |
27 | expect(input.prop('disabled')).toEqual(true);
28 | expect(input.prop('alt')).toEqual('this is my select');
29 | expect(input.prop('thisdoesnotexist')).toBeUndefined();
30 | });
31 | });
32 |
33 |
--------------------------------------------------------------------------------
/examples/src/server/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const express = require('express');
3 | const bodyParser = require('body-parser');
4 | const webpack = require('webpack');
5 | const webpackDevMiddleware = require('webpack-dev-middleware');
6 | const webpackHotMiddleware = require('webpack-hot-middleware');
7 | const historyApiFallback = require('connect-history-api-fallback');
8 |
9 | const webpackConfig = require('../client/config/dev.webpack.config.js');
10 | const buildPath = path.resolve(__dirname, "..", "client", "build");
11 | const generatedIndexHtmlPath = path.resolve(buildPath, 'index.html');
12 | const devMiddlewareConfig = {
13 | publicPath: webpackConfig.output.publicPath,
14 | stats: {
15 | colors: true,
16 | children: false,
17 | reasons: true,
18 | hash: false,
19 | timings: true,
20 | chunks: false,
21 | chunkModules: false
22 | }
23 | };
24 |
25 | const compiler = webpack(webpackConfig);
26 | const devMiddleware = webpackDevMiddleware(compiler, devMiddlewareConfig);
27 | const app = express();
28 |
29 | app.use(bodyParser.json());
30 | app.use(bodyParser.urlencoded({extended: true}));
31 | app.use(historyApiFallback());
32 | app.use(devMiddleware);
33 | app.use(webpackHotMiddleware(compiler));
34 |
35 | app.get('/', (req, res) =>
36 | res.send(devMiddleware.fileSystem.readFileSync(generatedIndexHtmlPath)));
37 |
38 | const port = process.env.PORT || 3000;
39 | app.listen(port, () => console.log("Listening on port :" + port));
40 |
--------------------------------------------------------------------------------
/test/Input/containers/InputContainer.proptypes.test.js:
--------------------------------------------------------------------------------
1 | import propTypes from "../../../src/Input/containers/InputContainer.proptypes";
2 |
3 | describe('InputContainer propTypes', () => {
4 | it('accepts valid props', () => {
5 | const validNameProp = propTypes.name({name: 'validGroup.validName'}, 'name', 'dummyComponent');
6 | expect(validNameProp).toBeUndefined();
7 |
8 | const validOnChangeProp = propTypes.onChange({onChange: e => e}, 'onChange', 'dummyComponent');
9 | expect(validOnChangeProp).toBeUndefined();
10 | });
11 |
12 | it('returns expected error on missing name property', () => {
13 | const invalidProps = propTypes.name({}, 'name', 'dummyComponent');
14 | expect(invalidProps).toBeInstanceOf(Error);
15 | expect(invalidProps.message).toContain('Value must be a valid string.');
16 | });
17 |
18 | it('returns expected error on invalid name property', () => {
19 | const invalidProps = propTypes.name({name: 'thisIsNotADotDelimitedString'}, 'name', 'dummyComponent');
20 | expect(invalidProps).toBeInstanceOf(Error);
21 | expect(invalidProps.message).toContain('Value must contain a dot-delimited namespace.');
22 | });
23 |
24 | it('returns expected error on invalid onChange property if present', () => {
25 | const invalidProps = propTypes.onChange({onCHange: 'thisIsNotAFunction'}, 'onChange', 'dummyComponent');
26 | expect(invalidProps).toBeInstanceOf(Error);
27 | expect(invalidProps.message).toContain('Value must be a function.');
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/Input/containers/InputContainer.js:
--------------------------------------------------------------------------------
1 | import {connect} from "react-redux";
2 | import dotProp from "dot-prop-immutable";
3 | import {changeField, createBoundType} from "../ducks/Input";
4 | import propTypes from "./InputContainer.proptypes";
5 |
6 | const getFieldNamespace = nameProp =>
7 | (nameProp.split('.')[0]);
8 |
9 | const InputContainer = component =>
10 | connect(
11 | state => state,
12 | dispatch => ({
13 | dispatch,
14 | }),
15 | (state, dispatch, own) => {
16 | const onChange = event => {
17 | const value = event.target.type === 'checkbox'
18 | ? event.target.checked
19 | : event.target.value;
20 |
21 | const namespace = getFieldNamespace(own.name);
22 | const type = createBoundType(namespace);
23 | return dispatch.dispatch(changeField(type, own.name, value));
24 | };
25 |
26 | const value =
27 | (own.type === "radio" && own.value) ||
28 | (own.name && dotProp.get(state, own.name) || '');
29 |
30 | return ({
31 | // Pass in received props first so defined props overwrite any preexisting ones.
32 | ...own,
33 | value: value,
34 | onChange: event => {
35 | const processedEvent = own.onChange ? own.onChange(event) : event;
36 | return processedEvent && onChange(processedEvent);
37 | },
38 | });
39 | },
40 | )(component);
41 |
42 | InputContainer.propTypes = propTypes;
43 |
44 | export default InputContainer;
--------------------------------------------------------------------------------
/examples/src/client/routes/CustomReducerActions.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Input } from '../../../../src';
5 | import StateAsText from '../components/StateAsText';
6 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
7 | import { resetForm, multiplyValues } from '../reducers/customReducerActions';
8 |
9 | const CustomReducerActionsForm = ({ reset, multiply }) =>
10 | (
11 |
15 |
21 |
27 |
Multiply values
28 |
Clear form
29 |
30 |
);
31 |
32 | CustomReducerActionsForm.propTypes = {
33 | reset: PropTypes.func.isRequired,
34 | multiply: PropTypes.func.isRequired,
35 | };
36 |
37 | export default connect(
38 | null,
39 | dispatch => ({
40 | reset: () => dispatch(resetForm()),
41 | multiply: () => dispatch(multiplyValues()),
42 | }),
43 | )(CustomReducerActionsForm);
44 |
--------------------------------------------------------------------------------
/examples/src/client/routes/InputTypes.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Input, Select, TextArea } from '../../../../src';
3 | import StateAsText from '../components/StateAsText';
4 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
5 |
6 | export default () =>
7 | (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Radio group
16 | True
17 |
18 |
19 | False
20 |
21 |
22 |
23 |
24 |
25 |
26 | Select one
27 | One
28 | Two
29 | Three
30 |
31 |
32 | );
33 |
--------------------------------------------------------------------------------
/examples/src/client/Application/Root.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter as Router, Link, Route } from 'react-router-dom';
3 | import Basic from '../routes/Basic';
4 | import InputTypes from '../routes/InputTypes';
5 | import Nested from '../routes/Nested';
6 | import DefaultState from '../routes/DefaultState';
7 | import InterceptOnChange from '../routes/InterceptOnChange';
8 | import CustomReducerActions from '../routes/CustomReducerActions';
9 | import OnStateChange from '../routes/OnStateChange';
10 | import AsyncValidation from '../routes/AsyncValidation';
11 | import './Application.scss';
12 |
13 | const Root = () =>
14 | (
15 |
16 |
17 | Basic
18 |
19 | Input types
20 |
21 | Nested
22 |
23 | Default state
24 |
25 | Intercept OnChange
26 |
27 | Custom reducer actions
28 |
29 | OnStateChange/form validation
30 |
31 | Async validation
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 |
46 | export default Root;
47 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {mount} from 'enzyme';
3 | import {Input, Select, TextArea} from "../src";
4 | import InputComponent from "../src/Input/components/Input";
5 | import SelectComponent from "../src/Input/components/Select";
6 | import TextAreaComponent from "../src/Input/components/TextArea";
7 | import InputContainer from "../src/Input/containers/InputContainer";
8 | import ConfiguredProvider from './util/fakes/ConfiguredProvider';
9 |
10 | describe('Exported components', () => {
11 | it('exports correctly decorated components', () => {
12 | const exportedComponentWrapper = mount(
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | const exportedInput = exportedComponentWrapper.find('input');
22 | const exportedSelect = exportedComponentWrapper.find('select');
23 | const exportedTextArea = exportedComponentWrapper.find('textarea');
24 |
25 | const InternalInput = InputContainer(InputComponent);
26 | const InternalSelect = InputContainer(SelectComponent);
27 | const InternalTextArea = InputContainer(TextAreaComponent);
28 | const internalComponentWrapper = mount(
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | const internalInput = internalComponentWrapper.find('input');
38 | const internalSelect = internalComponentWrapper.find('select');
39 | const internalTextArea = internalComponentWrapper.find('textarea');
40 |
41 | expect(exportedInput).toEqual(internalInput);
42 | expect(exportedSelect).toEqual(internalSelect);
43 | expect(exportedTextArea).toEqual(internalTextArea);
44 | });
45 | });
46 |
47 |
--------------------------------------------------------------------------------
/examples/src/client/config/prod.webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const paths = require('./paths');
4 |
5 | const htmlPluginConfig = {
6 | inject: true,
7 | template: paths.htmlPluginTemplate,
8 | };
9 |
10 | module.exports = {
11 | devtool: false,
12 | entry: paths.entry,
13 | output: {
14 | path: paths.buildPath,
15 | publicPath: '/',
16 | filename: 'bundle.js'
17 | },
18 | resolve: {
19 | extensions: ['.js', '.jsx', '.json'],
20 | },
21 | module: {
22 | rules: [
23 | {
24 | test: /\.jsx?$/,
25 | exclude: /node_modules/,
26 | loader: 'babel-loader',
27 | options: {
28 | babelrc: false,
29 | presets: ['es2015', 'react'],
30 | plugins: [require('babel-plugin-transform-object-rest-spread')],
31 | },
32 | },
33 | {
34 | test: /\.scss$/,
35 | include: paths.src,
36 | use: [
37 | 'style-loader',
38 | 'css-loader?sourceMap',
39 | 'resolve-url-loader',
40 | 'sass-loader?sourceMap',
41 | 'postcss-loader',
42 | ],
43 | },
44 | {
45 | test: /\.css$/,
46 | use: [
47 | 'style-loader',
48 | 'css-loader?sourceMap',
49 | ],
50 | },
51 | {
52 | test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
53 | include: paths.src,
54 | loader: 'url-loader',
55 | options: {
56 | limit: 10000,
57 | }
58 | },
59 | ],
60 | },
61 | plugins: [
62 | new HtmlWebpackPlugin(htmlPluginConfig),
63 | new webpack.DefinePlugin({'process.env.NODE_ENV': '"production"'}),
64 | new webpack.optimize.UglifyJsPlugin(),
65 | ],
66 | };
--------------------------------------------------------------------------------
/examples/src/client/routes/AsyncValidation.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Input } from '../../../../src';
5 | import StateAsText from '../components/StateAsText';
6 |
7 | import { asyncValidationAction } from '../reducers/asyncValidation';
8 | import ExampleDescription from '../components/ExampleDescription/ExampleDescription';
9 |
10 | const description = 'Demonstrates synchronous and asynchronous validation. ' +
11 | 'Async validation of Field1 is triggered when pressing "Save".';
12 |
13 | const AsyncValidationForm = ({ field1Invalid, field3Disabled, validate }) =>
14 | (
15 |
16 |
17 | Field1*
18 |
19 |
20 |
Hint: should not be a number
21 |
22 |
23 |
24 | Field2
25 |
26 |
27 |
28 |
29 |
30 | Field3
31 |
32 |
33 |
Hint: enabled when Field2 > 10
34 |
35 |
36 |
Save
37 |
38 |
39 |
);
40 |
41 | AsyncValidationForm.propTypes = {
42 | field1Invalid: PropTypes.bool,
43 | field3Disabled: PropTypes.bool,
44 | validate: PropTypes.func.isRequired,
45 | };
46 |
47 | AsyncValidationForm.defaultProps = {
48 | field1Invalid: false,
49 | field3Disabled: false,
50 | };
51 |
52 | export default connect(
53 | state => ({
54 | field1: state.async.field1,
55 | field1Invalid: state.asyncValidation.field1Invalid,
56 | field3Disabled: state.async.field2 <= 10,
57 | }),
58 | dispatch => ({
59 | validate: value => asyncValidationAction(value, dispatch),
60 | }),
61 | (state, dispatch) => ({
62 | validate: () => dispatch.validate(state.field1),
63 | field1Invalid: state.field1Invalid,
64 | field3Disabled: state.field3Disabled,
65 | }),
66 | )(AsyncValidationForm);
67 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "simple",
4 | "version": "0.0.1",
5 | "description": "Simple light-form example",
6 | "main": "index.js",
7 | "scripts": {
8 | "start": "node src/server",
9 | "build": "webpack --config ./src/client/config/prod.webpack.config.js"
10 | },
11 | "author": "Jonas Jensen",
12 | "license": "ISC",
13 | "dependencies": {
14 | "body-parser": "^1.17.1",
15 | "currency-formatter": "^1.2.1",
16 | "email-validator": "^1.0.7",
17 | "express": "^4.15.2",
18 | "light-form": "^2.2.0",
19 | "promise-polyfill": "^6.0.2",
20 | "prop-types": "^15.5.10",
21 | "react": "^15.5.4",
22 | "react-dom": "^15.5.4",
23 | "react-hot-loader": "^3.0.0-beta.6",
24 | "react-json-pretty": "^1.6.3",
25 | "react-redux": "^5.0.4",
26 | "react-router-dom": "^4.1.1",
27 | "redux": "^3.6.0",
28 | "redux-pack": "^0.1.5",
29 | "redux-thunk": "^2.2.0",
30 | "sakura.css": "^1.0.0",
31 | "whatwg-fetch": "^2.0.3"
32 | },
33 | "devDependencies": {
34 | "autoprefixer": "^7.1.0",
35 | "babel-core": "^6.24.1",
36 | "babel-eslint": "^7.2.3",
37 | "babel-loader": "^7.0.0",
38 | "babel-plugin-transform-object-rest-spread": "^6.23.0",
39 | "babel-preset-es2015": "^6.24.1",
40 | "babel-preset-react": "^6.24.1",
41 | "connect-history-api-fallback": "^1.3.0",
42 | "cross-env": "^5.0.0",
43 | "css-loader": "^0.28.1",
44 | "eslint": "^4.1.0",
45 | "eslint-config-airbnb": "^15.0.0",
46 | "eslint-loader": "^1.7.1",
47 | "eslint-plugin-import": "^2.2.0",
48 | "eslint-plugin-jsx-a11y": "^5.0.1",
49 | "eslint-plugin-react": "^7.0.1",
50 | "extract-text-webpack-plugin": "^2.1.0",
51 | "file-loader": "^0.11.1",
52 | "html-webpack-plugin": "^2.28.0",
53 | "mocha": "^3.4.1",
54 | "node-sass": "^4.5.2",
55 | "postcss-loader": "^2.0.5",
56 | "pushstate-server": "^3.0.0",
57 | "resolve-url-loader": "^2.0.2",
58 | "rimraf": "^2.6.1",
59 | "sass-loader": "^6.0.5",
60 | "style-loader": "^0.18.2",
61 | "url-loader": "^0.5.8",
62 | "webpack": "^3.0.0",
63 | "webpack-dev-middleware": "^1.10.2",
64 | "webpack-hot-middleware": "^2.18.0"
65 | },
66 | "eslintConfig": {
67 | "parser": "babel-eslint",
68 | "extends": "airbnb",
69 | "env": {
70 | "browser": true
71 | },
72 | "rules": {
73 | "max-len": [
74 | 1,
75 | 120
76 | ]
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "light-form",
3 | "version": "2.4.0",
4 | "description": "Lightweight React/Redux form state management",
5 | "main": "dist/index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/j0nas/light-form.git"
9 | },
10 | "module": "dist/es/index.js",
11 | "jsnext:main": "dist/es/index.js",
12 | "scripts": {
13 | "test": "cross-env NODE_ENV=test jest",
14 | "test:ci": "cross-env NODE_ENV=test jest --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js",
15 | "test:watch": "cross-env NODE_ENV=test jest --watch",
16 | "test:coverage": "cross-env NODE_ENV=test jest --coverage",
17 | "prepublish": "npm run build",
18 | "build:min": "webpack && gzip-size ./dist/light-form.min.js",
19 | "build:clean": "rimraf ./dist",
20 | "build:umd": "cross-env NODE_ENV=umd babel ./src --out-dir ./dist/umd",
21 | "build:es": "cross-env NODE_ENV=es babel ./src --out-dir ./dist/es",
22 | "build": "npm run build:clean && npm run build:umd && npm run build:es"
23 | },
24 | "keywords": [
25 | "light-form",
26 | "form",
27 | "react",
28 | "redux",
29 | "abstraction",
30 | "state",
31 | "simple",
32 | "lightweight",
33 | "reactjs",
34 | "react-redux"
35 | ],
36 | "author": "Jonas Jensen (http://jonas-jsensen.com)",
37 | "license": "ISC",
38 | "dependencies": {
39 | "dot-prop-immutable": "^1.3.1",
40 | "prop-types": "^15.5.7"
41 | },
42 | "peerDependencies": {
43 | "react": "^15.0.0",
44 | "react-dom": "^15.0.0",
45 | "react-redux": "^5.0.0",
46 | "redux": "^3.0.0"
47 | },
48 | "devDependencies": {
49 | "babel-cli": "^6.24.1",
50 | "babel-jest": "^20.0.1",
51 | "babel-loader": "^7.1.1",
52 | "babel-plugin-transform-object-rest-spread": "^6.23.0",
53 | "babel-plugin-transform-react-remove-prop-types": "^0.4.5",
54 | "babel-preset-es2015": "^6.24.1",
55 | "babel-preset-react": "^6.24.1",
56 | "coveralls": "^2.13.1",
57 | "cross-env": "^5.0.0",
58 | "enzyme": "^2.8.2",
59 | "gzip-size-cli": "^2.0.0",
60 | "jest": "^20.0.4",
61 | "react": "^15.5.4",
62 | "react-dom": "^15.5.4",
63 | "react-redux": "^5.0.4",
64 | "react-test-renderer": "^15.5.4",
65 | "redux": "^3.7.1",
66 | "rimraf": "^2.6.1",
67 | "webpack": "^3.0.0"
68 | },
69 | "jest": {
70 | "coverageDirectory": "./coverage/",
71 | "collectCoverage": true,
72 | "moduleFileExtensions": [
73 | "js",
74 | "jsx"
75 | ],
76 | "collectCoverageFrom": [
77 | "src/**"
78 | ]
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/test/Input/containers/InputContainer.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {mount} from "enzyme";
3 | import Input from "../../../src/Input/components/Input";
4 | import InputContainer from "../../../src/Input/containers/InputContainer";
5 | import ConfiguredProvider, {generateStore} from "../../util/fakes/ConfiguredProvider";
6 |
7 | const mountComponent = ({name, type, onChange, store} = {}) => {
8 | const InputContainerComponent = InputContainer(Input);
9 | return mount(
10 |
11 |
16 | ,
17 | );
18 | };
19 |
20 | describe('InputContainer', () => {
21 | let store;
22 |
23 | beforeEach(() => store = generateStore());
24 |
25 | it('decorates provided components with an onChange', () => {
26 | expect(mount( ).find('input').prop('onChange')).toBeUndefined();
27 | expect(mountComponent().find('input').prop('onChange')).toBeInstanceOf(Function);
28 | });
29 |
30 | it('dispatches updates when it is changed', () => {
31 | const input = mountComponent({store}).find('input').first();
32 | input.simulate('change', {target: {name: 'test.input', value: 'test'}});
33 | expect(store.getState()).toEqual(expect.objectContaining({test: {input: 'test'}}));
34 | });
35 |
36 | it('returns a boolean when input with type=checkbox is changed', () => {
37 | const input = mountComponent({type: 'checkbox', store}).find('input');
38 | expect(input.prop('checked')).toBeFalsy();
39 |
40 | const mockCheckboxEvent = value =>
41 | ({target: {name: 'test.input', type: 'checkbox', checked: value}});
42 |
43 | input.simulate('change', mockCheckboxEvent(true));
44 | expect(store.getState().test.input).toEqual(true);
45 |
46 | input.simulate('change', mockCheckboxEvent(false));
47 | expect(store.getState().test.input).toEqual(false);
48 | });
49 |
50 | it('calls custom onChange handlers', () => {
51 | let onChangeCalled = false;
52 |
53 | function onChange(e) {
54 | onChangeCalled = true;
55 | return e;
56 | }
57 |
58 | const input = mountComponent({onChange, store}).find('input').first();
59 | input.simulate('change', {target: {name: 'test.input', value: 'changed'}});
60 |
61 | expect(onChangeCalled).toBe(true);
62 | expect(store.getState().test.input).toBe('changed');
63 | });
64 |
65 | it('decorates provided components with an onChange', () => {
66 | const radio = mountComponent({type: 'radio', store}).find('input');
67 | radio.simulate('change', {target: {name: 'test.input', value: 'changed'}});
68 |
69 | expect(store.getState().test.input).toBe('changed');
70 | });
71 | });
--------------------------------------------------------------------------------
/test/Input/ducks/Input.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Input, {changeField, createBoundType} from "../../../src/Input/ducks/Input";
3 | import {createStore} from "redux";
4 |
5 | describe('Input duck', () => {
6 | const namespace = 'test';
7 | const actionType = createBoundType(namespace);
8 |
9 | it('handles actions', () => {
10 | const reducer = Input(namespace);
11 | const store = createStore(reducer);
12 | expect(store.getState()).toEqual({});
13 |
14 | store.dispatch(changeField(actionType, 'testInput', 'testValue'));
15 | const expectedState = {testInput: 'testValue'};
16 | expect(store.getState()).toEqual(expectedState);
17 | });
18 |
19 | it('calls onStateChange with the new state if the attribute is set', () => {
20 | const mockOnStateChange = jest.fn(state => state);
21 | const reducer = Input(namespace, {}, mockOnStateChange);
22 | const store = createStore(reducer);
23 | expect(store.getState()).toEqual({});
24 |
25 | store.dispatch(changeField(actionType, 'testInput', 'testValue'));
26 | const expectedState = {testInput: 'testValue'};
27 | expect(store.getState()).toEqual(expectedState);
28 | expect(mockOnStateChange).toHaveBeenCalledTimes(1);
29 | expect(mockOnStateChange).toHaveBeenCalledWith(expectedState);
30 | });
31 |
32 | it('applies the state returned from onStateChange', () => {
33 | const mockOnStateChange = jest.fn(state => ({
34 | ...state,
35 | ...(state.expectedPropWasPassed && {valueSetReactivelyToExpectedStateChange: true}),
36 | }));
37 | const reducer = Input(namespace, {}, mockOnStateChange);
38 | const store = createStore(reducer);
39 | expect(store.getState()).toEqual({});
40 |
41 | store.dispatch(changeField(actionType, 'UNexpectedPropWasPassed', true));
42 | expect(store.getState()).toEqual({UNexpectedPropWasPassed: true});
43 |
44 | store.dispatch(changeField(actionType, 'expectedPropWasPassed', true));
45 | expect(mockOnStateChange).toHaveBeenCalledTimes(2);
46 | expect(mockOnStateChange).toHaveBeenLastCalledWith({UNexpectedPropWasPassed: true, expectedPropWasPassed: true});
47 | expect(store.getState()).toEqual({
48 | expectedPropWasPassed: true,
49 | UNexpectedPropWasPassed: true,
50 | valueSetReactivelyToExpectedStateChange: true,
51 | });
52 | });
53 |
54 | it('applies custom action handlers if provided', () => {
55 | const TEST_CUSTOM_TYPE = 'TEST_CUSTOM_TYPE';
56 | const actionHandlers = {
57 | [TEST_CUSTOM_TYPE]: (state, action) => ({...state, addedValue: action.testValue })
58 | };
59 |
60 | const initialState = {initialValue: 'test'};
61 | const reducer = Input(namespace, initialState, null, actionHandlers);
62 | const store = createStore(reducer);
63 |
64 | store.dispatch({type: TEST_CUSTOM_TYPE, testValue: 'newValue'});
65 | expect(store.getState()).toEqual({initialValue: 'test', addedValue: 'newValue'});
66 | });
67 | });
--------------------------------------------------------------------------------
/examples/src/client/config/dev.webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const paths = require('./paths');
4 |
5 | const htmlPluginConfig = {
6 | inject: true,
7 | template: paths.htmlPluginTemplate,
8 | };
9 |
10 | module.exports = {
11 | devtool: 'eval-cheap-module-source-map',
12 | entry: [
13 | 'webpack-hot-middleware/client',
14 | 'react-hot-loader/patch',
15 | paths.entry,
16 | ],
17 | output: {
18 | path: paths.buildPath,
19 | publicPath: '/',
20 | filename: 'bundle.js'
21 | },
22 | resolve: {
23 | extensions: ['.js', '.jsx', '.json'],
24 | },
25 | module: {
26 | rules: [
27 | {
28 | test: /\.jsx?$/,
29 | loader: 'eslint-loader',
30 | include: paths.src,
31 | enforce: 'pre',
32 | options: {
33 | fix: true,
34 | },
35 | },
36 | {
37 | test: /\.jsx?$/,
38 | exclude: /node_modules/,
39 | loader: 'babel-loader?cacheDirectory',
40 | options: {
41 | babelrc: false,
42 | presets: ['es2015', 'react'],
43 | plugins: [require('babel-plugin-transform-object-rest-spread'), require('react-hot-loader/babel')],
44 | },
45 | },
46 | {
47 | test: /\.scss$/,
48 | include: paths.src,
49 | use: [
50 | 'style-loader',
51 | 'css-loader?sourceMap',
52 | 'resolve-url-loader',
53 | 'sass-loader?sourceMap',
54 | 'postcss-loader',
55 | ],
56 | },
57 | {
58 | test: /\.scss$/,
59 | exclude: paths.src,
60 | use: [
61 | 'style-loader',
62 | 'css-loader?sourceMap',
63 | 'resolve-url-loader',
64 | 'sass-loader?sourceMap',
65 | ],
66 | },
67 | {
68 | test: /\.css$/,
69 | use: [
70 | 'style-loader',
71 | 'css-loader?sourceMap',
72 | ],
73 | },
74 | {
75 | test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
76 | include: paths.src,
77 | loader: 'url-loader',
78 | options: {
79 | limit: 10000,
80 | }
81 | },
82 | ],
83 | },
84 | plugins: [
85 | new HtmlWebpackPlugin(htmlPluginConfig),
86 | new webpack.DefinePlugin({'process.env.NODE_ENV': '"development"'}),
87 | new webpack.HotModuleReplacementPlugin(),
88 | ],
89 | };
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Light-form
2 | =========================
3 | > Lightweight library for boilerplate-free React/Redux forms
4 |
5 | [](https://circleci.com/gh/j0nas/light-form/tree/master)
6 | [](https://coveralls.io/github/j0nas/light-form)
7 |
8 | **light-form** is a lightweight library that lets you create boilerplate-free React/Redux.
9 |
10 | Check out the [live demo][surge] and the [examples].
11 |
12 | ## Installation
13 | ```
14 | npm install --save light-form
15 | ```
16 |
17 | ## Example
18 | ```jsx harmony
19 | // CustomerForm.jsx
20 | import React from 'react';
21 | import {Input} from 'light-form';
22 |
23 | const CustomerForm = () =>
24 |
25 |
26 |
27 |
;
28 |
29 | export default CustomerForm;
30 | ```
31 |
32 | Import `Reducer` and pass it the name which you alias the reducer to. This should be the same
33 | as the first part of the dot-delimited `name` property for the fields in your form.
34 | Then add the reducer to your store setup (eg. using ``combineReducers``).
35 | ```jsx harmony
36 | // rootReducer.js
37 | import {combineReducers} from 'redux';
38 | import {Reducer} from 'light-form';
39 |
40 | const rootReducer = combineReducers({
41 | customer: Reducer('customer'),
42 | // .. other reducers
43 | });
44 |
45 | export default rootReducer;
46 | ```
47 |
48 | ## Quick start
49 | **light-form** only requires four simple steps to get started:
50 | * in your view, `import {Input, Select, TextArea} from "light-form";`
51 | * pass the components a `name` prop in the form of `[formName].[fieldName]`
52 | * (eg. ` `)
53 | * in your root reducer, `import {Reducer} from "light-form";`
54 | * pass it `[formName]` and add it to your store under the same name
55 | * (eg. `combineReducers({ myForm: Reducer('myForm'), ... })`)
56 |
57 | And that's it, your form is ready!
58 |
59 | ## How it works
60 | **light-form** exports ` `, ` ` and `` components.
61 | These components come with `value` and `onChange` props attached under the hood.
62 | Those props are wired up to the reducer with the matching name, eg. `customer` in the
63 | example above. So entering 'Jonas' and 'Jensen' into the example form above would give
64 | us this state tree:
65 | ```js
66 | {
67 | customer: {
68 | firstname: 'Jonas',
69 | lastname: 'Jensen'
70 | }
71 | }
72 | ```
73 |
74 | The components' `value` prop is handled in the reducer and should never be explicitly set.
75 | The `onChange` prop is intercepted by the components' container, if defined. See 'Defining
76 | custom onChange handlers' below.
77 |
78 | ## Why it's useful
79 | **light-form** aims to combine ease of use with flexibility. The following are its strong
80 | points.
81 |
82 | ### Reduced boilerplate
83 | Mapping and attaching `value` and `onChange` props is done the same way in most use cases,
84 | so light-form abstracts that away. The same applies for the reducer which handles those
85 | props. Rather than typing out repetitive code, we can focus on the domain aspects which
86 | makes our forms unique. To demonstrate this, compare the example above to the
87 | [equivalent][vanilla gist] form in "vanilla" React/Redux. This grows more beneficial with
88 | increased complexity, such as with multi-part forms. See *Nested* demo/example.
89 |
90 | ### No abstraction trade-off
91 | You can opt to have complete control of the form's events. The onChange prop and the reducer
92 | action handler have hooks which you can use to intercept the changes and perform mutations.
93 | This allows for have fine-tuned control where necessary, just like with vanilla React/Redux.
94 | See *InterceptOnChange* and *OnStateChange* demo/examples.
95 |
96 | ### No ad-hoc magic
97 | You can treat the provided components almost as standard
98 | [uncontrolled React components][uncontrolled], except they're in sync with the Redux store
99 | by default. Any props you pass them are applied. Eg., the provided ` ` is just a
100 | wrapper for a standard ` `, and will accept any props that would be valid for
101 | ` `. `value` and `onChange` are the exceptions, see "Defining custom event handlers".
102 |
103 | ## Defining custom event handlers
104 | The exported components and reducer have hooks which you can pass functions to. This allows
105 | for fine-grained control of the events passed to the components and the resulting state
106 | on reducer changes.
107 |
108 | ### Custom onChange handlers for fields
109 | If the `onChange` prop of a field is defined, the passed function will be invoked, and the
110 | return of that function will be passed to the internal onChange function. This allows for
111 | complete control of the onChange handling and outcome. The function passed to the prop will
112 | receive the event object as a parameter which you are free to copy and mutate as you please.
113 | Return this event object (or a copy) as a part of the custom onChange function, or a falsy
114 | value if you want to abort handling the event. See *Intercept OnChange* example.
115 |
116 | ### Custom onStateChange handler for reducer
117 | In addition to an optional *defaultState* second parameter, the Reducer accepts an
118 | `onStateChange` function as an optional third parameter. If present, the passed function
119 | will be invoked after a state update has occurred, and the function will receive the updated
120 | state as a parameter. The function is free to mutate this state as needed. The function is
121 | expected to return an object, which will be applied as the new state for the reducer. See
122 | *OnStateChange* example.
123 |
124 | ### Custom reducer actions
125 | The Reducer accepts an optional `actionHandlers` object as the fourth parameter. This is
126 | expected to be an object with Redux action names as keys and state migration functions
127 | (like in conventional reducers) as values. The functions will receive `state` and `action`
128 | parameters, being the reducer's state and the dispatched action respectively. The value
129 | returned from the function will be the new state. See *Custom reducer actions* example.
130 |
131 | [vanilla gist]: https://gist.github.com/j0nas/d597b3e7f6a6718f9c7c8ea0734d8c47
132 | [surge]: http://light-form.surge.sh
133 | [examples]: https://github.com/j0nas/light-form/tree/master/examples
134 | [uncontrolled]: https://facebook.github.io/react/docs/uncontrolled-components.html
--------------------------------------------------------------------------------