├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .storybook ├── addons.js └── config.js ├── CHANGELOG.md ├── README.md ├── package.json ├── src ├── FormProvider.js ├── Schema.js ├── _formContextTypes.js ├── createHydrateProvider.js ├── getErrorFields.js ├── index.js ├── scrollToInvalidKey.js ├── setErrors.js ├── withErrorQuery.js ├── withForm.js ├── withFormClient.js ├── withFormContext.js ├── withFormData.js ├── withFormOnChange.js ├── withFormSubmit.js ├── withInitialData.js ├── withInput.js ├── withSetErrors.js ├── withSetFieldError.js └── withValidationMessage.js ├── stories ├── Inputs.js ├── SimpleForm.js └── index.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@workpop/eslint-config-workpop", 4 | "plugin:import/errors" 5 | ], 6 | "rules": { 7 | "react/jsx-filename-extension": 0, 8 | "react/no-find-dom-node": 0, 9 | "no-underscore-dangle": 1, 10 | "new-cap": 0, 11 | "max-len": 0, 12 | "prefer-arrow-callback": 0, 13 | "no-use-before-define": 0, 14 | "arrow-body-style": 0, 15 | "dot-notation": 0, 16 | "no-console": 1, 17 | "no-param-reassign": 1, 18 | "flowtype/define-flow-type": 1, 19 | "flowtype/require-parameter-type": 1, 20 | "flowtype/require-return-type": [ 21 | 1, 22 | "always", 23 | { 24 | "annotateUndefined": "never" 25 | } 26 | ], 27 | "flowtype/space-after-type-colon": [ 28 | 1, 29 | "always" 30 | ], 31 | "flowtype/space-before-type-colon": [ 32 | 1, 33 | "never" 34 | ], 35 | "flowtype/type-id-match": [ 36 | 1, 37 | "^([A-Z][a-z0-9]+)+Type$" 38 | ], 39 | "flowtype/use-flow-type": 1, 40 | "flowtype/valid-syntax": 1, 41 | "react/jsx-uses-react": "error", 42 | "react/jsx-uses-vars": "error", 43 | "jsx-a11y/no-static-element-interactions": "warn", 44 | "class-methods-use-this": "warn" 45 | }, 46 | "parser": "babel-eslint", 47 | "plugins": [ 48 | "flowtype", 49 | "react" 50 | ], 51 | "env": { 52 | "mocha": true 53 | }, 54 | "settings": { 55 | "flowtype": { 56 | "onlyFilesWithFlowAnnotation": true 57 | } 58 | }, 59 | "globals": { 60 | "React$Node": false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | dist 5 | lib 6 | .idea/ 7 | .DS_Store 8 | yarn-error.log 9 | .tmp/ 10 | lerna-debug.log 11 | lerna-commit.txt 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | coverage 4 | resources 5 | flow-typed 6 | stories 7 | .storybook 8 | __tests__ 9 | .tmp/ 10 | build/ 11 | yarn-error.log 12 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure, addDecorator } from '@storybook/react'; 2 | import apolloStorybookDecorator from 'apollo-storybook-react'; 3 | 4 | const typeDefs = ` 5 | input PersonInput { 6 | name: String! 7 | age: Int! 8 | description: String! 9 | } 10 | 11 | type Person { 12 | name: String 13 | age: Int 14 | description: String 15 | } 16 | 17 | type Query { 18 | sampleForm: Person 19 | } 20 | 21 | type Mutation { 22 | createSample(inputData: PersonInput): Boolean 23 | } 24 | `; 25 | 26 | addDecorator( 27 | apolloStorybookDecorator({ 28 | typeDefs, 29 | mocks: {}, 30 | }) 31 | ); 32 | 33 | function loadStories() { 34 | require('../stories'); 35 | } 36 | 37 | configure(loadStories, module); 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | # 0.1.0 (2017-12-02) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * **input:** controlled input fix ([16c5799](https://github.com/abhiaiyer91/apollo-forms/commit/16c5799)) 12 | * **onBlur:** add on blur ([593f255](https://github.com/abhiaiyer91/apollo-forms/commit/593f255)) 13 | 14 | 15 | ### Features 16 | 17 | * **cleanup:** field error clean up ([7f55279](https://github.com/abhiaiyer91/apollo-forms/commit/7f55279)) 18 | * **cleanup:** field error clean up ([88acafa](https://github.com/abhiaiyer91/apollo-forms/commit/88acafa)) 19 | * **errors:** provide form errors via apollo-link-state ([126c9f7](https://github.com/abhiaiyer91/apollo-forms/commit/126c9f7)) 20 | * **hydration:** hydration utilities ([22748af](https://github.com/abhiaiyer91/apollo-forms/commit/22748af)) 21 | * **scroll:** scroll to invalid key ([695ccbb](https://github.com/abhiaiyer91/apollo-forms/commit/695ccbb)) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creating a Form 2 | 3 | ## 1. Create a Client query 4 | 5 | ### 1a. Create a fragment to represent your form field keys 6 | 7 | ```js 8 | import gql from 'graphql-tag'; 9 | 10 | const fragment = gql` 11 | fragment client on ClientData { 12 | name 13 | age 14 | } 15 | `; 16 | ``` 17 | 18 | ### 1b. Create a query for your form state 19 | 20 | ```js 21 | import gql from 'graphql-tag'; 22 | 23 | const inputQuery = gql` 24 | ${fragment} 25 | { 26 | sampleForm @client { 27 | ...client 28 | } 29 | } 30 | `; 31 | ``` 32 | 33 | ### 1b. Create a query to represent your error state 34 | 35 | Error queries are namespaced like so: `{FORM_NAME}Errors` 36 | 37 | ```js 38 | import gql from 'graphql-tag'; 39 | 40 | const errorsQuery = gql` 41 | ${fragment} 42 | { 43 | sampleFormErrors @client { 44 | ...client 45 | } 46 | } 47 | `; 48 | ``` 49 | 50 | ## 2. Creating Initial Props 51 | 52 | ### 2a. Create a validator 53 | 54 | ```js 55 | import { combineValidators, composeValidators, isAlphabetic, isNumeric, isRequired } from 'revalidate'; 56 | 57 | const validator = combineValidators({ 58 | name: composeValidators(isRequired, isAlphabetic)('Name'), 59 | age: composeValidators(isRequired, isNumeric)('Age'), 60 | }); 61 | ``` 62 | 63 | ### 2b. Supply Initial State 64 | 65 | ```js 66 | const initialData = { 67 | name: null, 68 | age: null, 69 | } 70 | ``` 71 | 72 | ## 3. Create a Form Provider w/ formName, and initialData 73 | 74 | ### 3a. Create your Submit Mutation 75 | ```js 76 | const sampleMutation = gql` 77 | mutation($inputData: PersonInput) { 78 | createSample(inputData: $inputData) 79 | } 80 | `; 81 | ``` 82 | 83 | ### 3b. Create your form 84 | ```js 85 | import { createForm, FormSchema, FormProvider } from 'apollo-forms'; 86 | 87 | const Form = createForm({ mutation: sampleMutation, inputQuery, errorsQuery })(FormProvider); 88 | ``` 89 | 90 | ### 3c. Pass in initialData and a formName 91 | 92 | ```js 93 | export default function Root() { 94 | return ( 95 |
99 |
100 | ); 101 | } 102 | ``` 103 | 104 | ## 4. Create an Input w/ a field prop 105 | 106 | ```js 107 | import { withInput } from 'apollo-forms'; 108 | 109 | const Input = withInput('input'); 110 | 111 | export default function Root() { 112 | return ( 113 |
116 | 119 | 123 |
124 | ); 125 | } 126 | ``` 127 | 128 | ## 5. Add a Submit Control 129 | 130 | ```js 131 | export default function Root() { 132 | return ( 133 |
136 | 139 | 143 | 144 |
145 | ); 146 | } 147 | ``` 148 | 149 | # Hydrating a Form 150 | 151 | As long as a `FormProvider` gets `initialData` the form will hydrate the appropriate fields in the form. 152 | There are some utils provided that may help you hydrate your Form: 153 | 154 | ## 1. Create a HydrateProvider 155 | 156 | ```js 157 | import { createHydrateProvider } from 'apollo-forms'; 158 | 159 | const query = gql` 160 | { 161 | query sample { 162 | sampleForm { 163 | name 164 | age 165 | } 166 | } 167 | } 168 | `; 169 | 170 | const HydrateProvider = createHydrateProvider({ 171 | query, 172 | queryKey: 'sampleForm', 173 | }); 174 | ``` 175 | 176 | ## 2. Use a render prop to pass it into your form: 177 | 178 | ```js 179 | export default function Root() { 180 | return ( 181 | 182 | {(data) => { 183 | return ( 184 |
188 | 189 | 190 | 191 | 192 | ); 193 | }} 194 |
195 | ); 196 | } 197 | ``` 198 | 199 | ## 3. Or use withHandlers 200 | 201 | ```js 202 | import { withHandlers } from 'recompose'; 203 | 204 | function Root({ renderForm }) { 205 | return ( 206 | 207 | {renderForm} 208 | 209 | ); 210 | } 211 | 212 | export default withHandlers({ 213 | renderForm: () => { 214 | return (data) => { 215 | return ( 216 |
220 | 221 | 222 | 223 | 224 | ); 225 | } 226 | } 227 | })(Root); 228 | ``` 229 | 230 | # How this works 231 | 232 | Under the hood, `apollo-forms` creates a `ApolloClient` instance with `apollo-linked-state`. The form gets its own 233 | state graph to work with keyed off `formName`. When `onChange` is called from the `Input` components, both internal react state is updated as well as the local `ApolloClient` cache. 234 | 235 | Validation through the `revalidate` library is run when the inputs have values and validation messages are passed as props to the base component. 236 | 237 | `onSubmit`, the `FormProvider` component takes the form state and passes it to the supplied `mutation` in the form. By default the variables are formatted like this: `{ inputData: FORM_STATE }`. To customize your mutation arguments, pass a `transform` to the FormProvider to return the form state however you wish. 238 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-forms", 3 | "version": "0.1.0", 4 | "description": "Form Bindings with Apollo", 5 | "main": "lib/index.js", 6 | "author": "Abhi Aiyer", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@storybook/addon-actions": "^3.2.16", 10 | "@storybook/addon-links": "^3.2.16", 11 | "@storybook/react": "^3.2.16", 12 | "@workpop/eslint-config-workpop": "^1.0.0", 13 | "apollo-cache-inmemory": "^1.1.1", 14 | "apollo-client": "^2.0.3", 15 | "apollo-link": "^1.0.3", 16 | "apollo-link-state": "^0.0.4", 17 | "apollo-storybook-react": "^0.1.0", 18 | "babel-preset-env": "^1.6.1", 19 | "eslint": "^3.19.0", 20 | "graphql": "^0.11.7", 21 | "graphql-tag": "^2.5.0", 22 | "graphql-tools": "^2.8.0", 23 | "lodash": "^4.17.4", 24 | "react": "^16.1.1", 25 | "react-apollo": "^2.0.1", 26 | "react-dom": "^16.1.1", 27 | "recompose": "^0.26.0", 28 | "revalidate": "^1.2.0", 29 | "smoothscroll": "^0.4.0", 30 | "standard-version": "^4.2.0" 31 | }, 32 | "peerDependencies": { 33 | "apollo-cache-inmemory": "^1.1.1", 34 | "apollo-client": "^2.0.3", 35 | "apollo-link": "^1.0.3", 36 | "apollo-link-state": "^0.0.4", 37 | "apollo-storybook-react": "^0.1.0", 38 | "babel-preset-env": "^1.6.1", 39 | "eslint": "^3.19.0", 40 | "graphql": "^0.11.7", 41 | "graphql-tag": "^2.5.0", 42 | "graphql-tools": "^2.8.0", 43 | "lodash": "^4.17.4", 44 | "react": "^16.1.1", 45 | "react-apollo": "^2.0.1", 46 | "react-dom": "^16.1.1", 47 | "recompose": "^0.26.0", 48 | "revalidate": "^1.2.0" 49 | }, 50 | "scripts": { 51 | "release": "standard-version", 52 | "prepublish": "babel ./src --ignore test --out-dir ./lib", 53 | "storybook": "start-storybook -p 6006", 54 | "build-storybook": "build-storybook" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/FormProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { pick } from 'lodash'; 3 | import { ApolloProvider } from 'react-apollo'; 4 | 5 | export default function FormProvider({ FormClient, children, ...rest }) { 6 | return ( 7 | 8 |
{children}
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/Schema.js: -------------------------------------------------------------------------------- 1 | export default class Schema { 2 | constructor({ validator, validationMessages = {}, model }) { 3 | this.validator = validator; 4 | this.validationMessages = validationMessages; 5 | this.model = model; 6 | } 7 | 8 | getInitialState() { 9 | return this.model; 10 | } 11 | 12 | validate(formData) { 13 | return this.validator(formData); 14 | } 15 | 16 | getValidationMessageByField({ formData, field, useCustomMessage = false }) { 17 | if (useCustomMessage) { 18 | return this.validationMessages[field]; 19 | } 20 | 21 | const message = this.validate(formData); 22 | 23 | return message && message[field]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/_formContextTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export default { 4 | FormClient: PropTypes.any, 5 | onChange: PropTypes.func, 6 | formName: PropTypes.string, 7 | schema: PropTypes.any, 8 | inputQuery: PropTypes.any, 9 | errorsQuery: PropTypes.any, 10 | initialData: PropTypes.any, 11 | }; 12 | -------------------------------------------------------------------------------- /src/createHydrateProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { graphql } from 'react-apollo'; 3 | import { compose, withProps } from 'recompose'; 4 | 5 | function FormRender({ LoadingComponent, loading, children, data }) { 6 | if (loading && !!LoadingComponent) { 7 | return ; 8 | } 9 | 10 | if (loading && !LoadingComponent) { 11 | return null; 12 | } 13 | 14 | return children(data); 15 | } 16 | 17 | function withFormHydrate({ queryKey, query, options = {} }) { 18 | if (!queryKey) { 19 | throw new Error('Must provide queryKey'); 20 | } 21 | return compose( 22 | graphql(query, options), 23 | withProps(({ data }) => { 24 | return { 25 | data: data && data[queryKey], 26 | loading: data && data.loading, 27 | }; 28 | }) 29 | ); 30 | } 31 | 32 | export default function createHydrateProvider({ 33 | queryKey, 34 | query, 35 | options, 36 | }) { 37 | return withFormHydrate({ queryKey, query, options })(FormRender); 38 | } 39 | -------------------------------------------------------------------------------- /src/getErrorFields.js: -------------------------------------------------------------------------------- 1 | export default function getErrorFields({ client, errorsQuery }) { 2 | let errorFields; 3 | 4 | try { 5 | errorFields = client.readQuery({ query: errorsQuery }); 6 | } catch (error) { 7 | errorFields = {}; 8 | } 9 | 10 | return errorFields; 11 | } 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export createForm from './withForm'; 2 | export withInput from './withInput'; 3 | export FormSchema from './Schema'; 4 | -------------------------------------------------------------------------------- /src/scrollToInvalidKey.js: -------------------------------------------------------------------------------- 1 | import { isUndefined, get, isFunction } from 'lodash'; 2 | import smoothscroll from 'smoothscroll'; 3 | 4 | function offset(el) { 5 | if (!el || !isFunction(el && el.getBoundingClientRect)) { 6 | return { 7 | top: 0, 8 | left: 0, 9 | }; 10 | } 11 | 12 | const rect = el.getBoundingClientRect(); 13 | const top = get(rect, 'top'); 14 | const left = get(rect, 'left'); 15 | const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 16 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 17 | 18 | return { top: top + scrollTop, left: left + scrollLeft }; 19 | } 20 | 21 | /** 22 | * Scroll to the field name that is invalid 23 | * @param keyName 24 | * @returns {*} 25 | */ 26 | export default function scrollToInvalidKey(keyName) { 27 | const VALIDATION_SCROLL_OFFSET = 150; 28 | const labelSelector = document.querySelector(`label[for="${keyName}"]`); 29 | const invalidKeyScrollTop = get(offset(labelSelector), 'top'); 30 | if (isUndefined(invalidKeyScrollTop)) { 31 | return; 32 | } 33 | const scrollTop = invalidKeyScrollTop - VALIDATION_SCROLL_OFFSET; 34 | return smoothscroll(scrollTop); 35 | } 36 | -------------------------------------------------------------------------------- /src/setErrors.js: -------------------------------------------------------------------------------- 1 | import getErrorFields from './getErrorFields'; 2 | 3 | export default function setErrors({ client, query, formName, errorMessage }) { 4 | const errorFields = getErrorFields({ client, errorsQuery: query }); 5 | 6 | let errorData = errorFields[`${formName}Errors`]; 7 | 8 | errorData = { 9 | ...errorData, 10 | ...errorMessage, 11 | }; 12 | 13 | errorFields[`${formName}Errors`] = errorData; 14 | 15 | client.writeQuery({ 16 | query: query, 17 | data: errorFields, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/withErrorQuery.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { graphql } from 'react-apollo'; 3 | 4 | export default function withErrorQuery(BaseComponent) { 5 | return ({ errorsQuery, ...rest }) => { 6 | const WrappedBaseComponent = graphql(errorsQuery, { 7 | name: 'errorData', 8 | })(BaseComponent); 9 | 10 | return ; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/withForm.js: -------------------------------------------------------------------------------- 1 | import { compose, withProps } from 'recompose'; 2 | import withInitialData from './withInitialData'; 3 | import withFormClient from './withFormClient'; 4 | import withFormData from './withFormData'; 5 | import withFormOnChange from './withFormOnChange'; 6 | import withFormContext from './withFormContext'; 7 | import withFormSubmit from './withFormSubmit'; 8 | 9 | export default function createForm({ mutation, inputQuery, errorsQuery }) { 10 | return compose( 11 | withInitialData, 12 | withProps({ 13 | inputQuery, 14 | errorsQuery, 15 | }), 16 | withFormClient, 17 | withFormData, 18 | withFormOnChange, 19 | withFormContext, 20 | withFormSubmit(mutation), 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/withFormClient.js: -------------------------------------------------------------------------------- 1 | import { compose, withPropsOnChange, lifecycle } from 'recompose'; 2 | import { ApolloClient } from 'apollo-client'; 3 | import { InMemoryCache } from 'apollo-cache-inmemory'; 4 | import { withClientState } from 'apollo-link-state'; 5 | 6 | function createFormState(formName, model) { 7 | return withClientState({ 8 | Query: { 9 | [formName]: () => { 10 | return { 11 | ...model, 12 | __typename: formName, 13 | }; 14 | }, 15 | [`${formName}Errors`]: () => { 16 | const modelKeys = Object.keys(model); 17 | const initialModel = modelKeys.reduce((memo, currentVal) => { 18 | return { 19 | ...memo, 20 | [currentVal]: null, 21 | }; 22 | }, {}); 23 | return { 24 | ...initialModel, 25 | __typename: `${formName}Errors`, 26 | }; 27 | }, 28 | }, 29 | }); 30 | } 31 | 32 | export default compose( 33 | withPropsOnChange(['formName', 'schema'], ({ formName, schema }) => { 34 | const localState = createFormState(formName, schema.getInitialState()); 35 | 36 | const FormClient = new ApolloClient({ 37 | cache: new InMemoryCache(), 38 | link: localState, 39 | }); 40 | 41 | return { 42 | FormClient, 43 | }; 44 | }), 45 | lifecycle({ 46 | componentDidMount() { 47 | const { FormClient, inputQuery, errorsQuery } = this.props; 48 | 49 | if (!!errorsQuery) { 50 | FormClient.query({ query: errorsQuery }); 51 | } 52 | 53 | return FormClient.query({ query: inputQuery }); 54 | }, 55 | }) 56 | ); 57 | -------------------------------------------------------------------------------- /src/withFormContext.js: -------------------------------------------------------------------------------- 1 | import _formContextTypes from './_formContextTypes'; 2 | import { withContext } from 'recompose'; 3 | 4 | export default withContext( 5 | _formContextTypes, 6 | ({ 7 | schema, 8 | onChange, 9 | inputQuery, 10 | formName, 11 | FormClient, 12 | initialData, 13 | errorsQuery, 14 | }) => { 15 | return { 16 | initialData, 17 | schema, 18 | FormClient, 19 | onChange, 20 | inputQuery, 21 | errorsQuery, 22 | formName, 23 | }; 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /src/withFormData.js: -------------------------------------------------------------------------------- 1 | import { withProps } from 'recompose'; 2 | 3 | export default withProps(({ FormClient, formName, inputQuery, schema }) => { 4 | let currentData; 5 | 6 | try { 7 | currentData = FormClient.readQuery({ 8 | query: inputQuery, 9 | }); 10 | } catch (e) { 11 | currentData = {}; 12 | } 13 | 14 | const initialState = { 15 | ...schema.getInitialState(), 16 | __typename: formName, 17 | }; 18 | 19 | return { 20 | dataFromStore: currentData, 21 | formData: (currentData && currentData[formName]) || initialState, 22 | }; 23 | }); 24 | -------------------------------------------------------------------------------- /src/withFormOnChange.js: -------------------------------------------------------------------------------- 1 | import { withHandlers } from 'recompose'; 2 | 3 | export default withHandlers({ 4 | onChange: ({ FormClient, dataFromStore, formData, inputQuery, formName }) => { 5 | return ({ field, value, onUpdate }) => { 6 | if (!!field) { 7 | formData[field] = value; 8 | 9 | dataFromStore[formName] = formData; 10 | 11 | FormClient.writeQuery({ 12 | query: inputQuery, 13 | data: dataFromStore, 14 | }); 15 | 16 | if (typeof onUpdate === 'function') { 17 | setTimeout(() => { 18 | return onUpdate({ field, value }); 19 | }, 100); 20 | } 21 | } 22 | }; 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/withFormSubmit.js: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-apollo'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { noop, omit } from 'lodash'; 4 | import withSetErrors from './withSetErrors'; 5 | 6 | function defaultTransform(props) { 7 | return { inputData: omit(props, '__typename') }; 8 | } 9 | 10 | function defaultErrorLogger(e) { 11 | return console.error(e.message); 12 | } 13 | 14 | export default function (mutation) { 15 | return compose( 16 | graphql(mutation), 17 | withSetErrors, 18 | withHandlers({ 19 | onSubmit: ({ 20 | mutate, 21 | onSuccess = noop, 22 | onError = defaultErrorLogger, 23 | transform = defaultTransform, 24 | formData, 25 | variables = {}, 26 | setErrors = noop, 27 | }) => { 28 | return (e) => { 29 | e.preventDefault(); 30 | 31 | if (setErrors()) { 32 | return; 33 | } 34 | 35 | return mutate({ 36 | variables: { 37 | ...transform(formData), 38 | ...variables, 39 | }, 40 | }) 41 | .then(onSuccess) 42 | .catch(onError); 43 | }; 44 | }, 45 | }) 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/withInitialData.js: -------------------------------------------------------------------------------- 1 | import { withPropsOnChange } from 'recompose'; 2 | import FormSchema from './Schema'; 3 | 4 | export default withPropsOnChange(['initialData', 'validator'], ({ validator, initialData }) => { 5 | const Schema = new FormSchema({ 6 | model: initialData, 7 | validator, 8 | }); 9 | 10 | return { 11 | schema: Schema, 12 | }; 13 | }); 14 | -------------------------------------------------------------------------------- /src/withInput.js: -------------------------------------------------------------------------------- 1 | import { 2 | compose, 3 | getContext, 4 | withState, 5 | withHandlers, 6 | lifecycle, 7 | mapProps, 8 | withProps, 9 | } from 'recompose'; 10 | import withErrorQuery from './withErrorQuery'; 11 | import withSetFieldError from './withSetFieldError'; 12 | import _formContextTypes from './_formContextTypes'; 13 | import withValidationMessage from './withValidationMessage'; 14 | 15 | export default compose( 16 | getContext(_formContextTypes), 17 | withErrorQuery, 18 | withState('internalValue', 'setInternalValue', ({ initialData, field }) => { 19 | return (initialData && initialData[field]) || ''; 20 | }), 21 | withProps(({ FormClient, inputQuery }) => { 22 | let data; 23 | 24 | try { 25 | data = FormClient.readQuery({ query: inputQuery }); 26 | } catch (e) { 27 | data = {}; 28 | } 29 | 30 | return { 31 | data, 32 | }; 33 | }), 34 | withProps(({ internalValue, errorData = {}, formName, field }) => { 35 | const errorDataFromForm = errorData[`${formName}Errors`]; 36 | 37 | const errorDataForField = errorDataFromForm && errorDataFromForm[field]; 38 | 39 | return { 40 | validationMessage: errorDataForField, 41 | value: internalValue, 42 | }; 43 | }), 44 | lifecycle({ 45 | componentDidMount() { 46 | const { initialData, setInternalValue, field } = this.props; 47 | const initialValue = (initialData && initialData[field]) || ''; 48 | 49 | return setInternalValue(initialValue); 50 | }, 51 | }), 52 | withSetFieldError, 53 | withHandlers({ 54 | onChange: ({ field, setInternalValue, onChange, setFieldError }) => { 55 | return (e) => { 56 | const value = e.target.value; 57 | 58 | setInternalValue(value); 59 | 60 | return onChange({ 61 | field, 62 | value, 63 | onUpdate: () => { 64 | return setFieldError({ field, value }); 65 | }, 66 | }); 67 | }; 68 | }, 69 | }), 70 | withValidationMessage, 71 | mapProps(({ type, options, children, onChange, field, value }) => { 72 | return { 73 | type, 74 | options, 75 | onChange, 76 | name: field, 77 | value, 78 | children, 79 | }; 80 | }), 81 | ); 82 | -------------------------------------------------------------------------------- /src/withSetErrors.js: -------------------------------------------------------------------------------- 1 | import { withHandlers } from 'recompose'; 2 | import { noop } from 'lodash'; 3 | import scrollToInvalidKey from './scrollToInvalidKey'; 4 | import setErrors from './setErrors'; 5 | 6 | export default withHandlers({ 7 | setErrors: ({ 8 | schema, 9 | formData, 10 | FormClient, 11 | errorsQuery, 12 | formName, 13 | onErrorMessage = noop, 14 | scrollOnValidKey = true, 15 | }) => { 16 | return () => { 17 | const errorMessage = schema.validate(formData); 18 | 19 | const errorKeys = Object.keys(errorMessage); 20 | 21 | if (errorMessage && errorKeys && errorKeys.length > 0) { 22 | if (scrollOnValidKey) { 23 | scrollToInvalidKey(errorKeys[0]); 24 | } 25 | 26 | setErrors({ 27 | query: errorsQuery, 28 | client: FormClient, 29 | formName, 30 | errorMessage, 31 | }); 32 | 33 | onErrorMessage(errorMessage); 34 | 35 | return true; 36 | } 37 | return false; 38 | }; 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /src/withSetFieldError.js: -------------------------------------------------------------------------------- 1 | import { withHandlers } from 'recompose'; 2 | import setErrors from './setErrors'; 3 | 4 | export default withHandlers({ 5 | setFieldError: ({ formName, errorsQuery, FormClient, schema, formData }) => { 6 | return ({ field, value }) => { 7 | const schemaValidation = schema.validate({ 8 | ...formData, 9 | [field]: value, 10 | }); 11 | 12 | let isFieldInValidation; 13 | 14 | if (!!schemaValidation[field]) { 15 | isFieldInValidation = { [field]: schemaValidation[field] }; 16 | } else { 17 | isFieldInValidation = { [field]: null }; 18 | } 19 | 20 | return setErrors({ 21 | client: FormClient, 22 | formName, 23 | query: errorsQuery, 24 | errorMessage: isFieldInValidation, 25 | }); 26 | }; 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/withValidationMessage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function withValidationMessage(BaseComponent) { 4 | return ({ 5 | validationMessage, 6 | ValidationMessageComponent, 7 | ...rest 8 | }) => { 9 | return ( 10 |
11 | 12 | {!!validationMessage && (ValidationMessageComponent ||

{validationMessage}

)} 13 |
14 | ); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /stories/Inputs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import withInput from '../src/withInput'; 4 | 5 | export const Input = withInput('input'); 6 | 7 | export const TextArea = withInput('textarea'); 8 | 9 | const SelectInput = withInput('select'); 10 | 11 | export function Select({ options = [], ...rest }) { 12 | return ( 13 | 14 | {options.map(({ label, value }, index) => { 15 | return ( 16 | 19 | ); 20 | })} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /stories/SimpleForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import gql from 'graphql-tag'; 3 | import { 4 | combineValidators, 5 | composeValidators, 6 | isRequired, 7 | isAlphabetic, 8 | isNumeric, 9 | hasLengthGreaterThan, 10 | } from 'revalidate'; 11 | import createForm from '../src/withForm'; 12 | import FormProvider from '../src/FormProvider'; 13 | import { Input, TextArea, Select } from './Inputs'; 14 | 15 | function SubmitControls() { 16 | return ; 17 | } 18 | 19 | const sampleMutation = gql` 20 | mutation($inputData: PersonInput) { 21 | createSample(inputData: $inputData) 22 | } 23 | `; 24 | 25 | const fragment = gql` 26 | fragment client on ClientData { 27 | name 28 | age 29 | city 30 | description 31 | } 32 | `; 33 | 34 | const query = gql` 35 | { 36 | sampleForm @client { 37 | ...client 38 | } 39 | } 40 | ${fragment} 41 | `; 42 | 43 | const errorsQuery = gql` 44 | { 45 | sampleFormErrors @client { 46 | ...client 47 | } 48 | } 49 | ${fragment} 50 | `; 51 | 52 | const Form = createForm({ 53 | mutation: sampleMutation, 54 | inputQuery: query, 55 | errorsQuery: errorsQuery, 56 | })(FormProvider); 57 | 58 | const sampleValidator = combineValidators({ 59 | name: composeValidators(isRequired, isAlphabetic)('Name'), 60 | age: composeValidators(isRequired, isNumeric)('Age'), 61 | description: composeValidators(hasLengthGreaterThan('1'))('Description'), 62 | city: composeValidators(isRequired, hasLengthGreaterThan('1'))('City'), 63 | }); 64 | 65 | const initialData = { 66 | name: null, 67 | age: null, 68 | description: null, 69 | city: null, 70 | }; 71 | 72 | export default function SimpleForm() { 73 | return ( 74 |
{ 78 | return console.log('Submitted!'); 79 | }} 80 | onError={() => { 81 | console.log('ERRORED'); 82 | }} 83 | onErrorMessage={(errorMessage) => { 84 | const errorKeys = Object.keys(errorMessage); 85 | console.log('**** ERRORS', errorMessage[errorKeys[0]]); 86 | }} 87 | formName="sampleForm" 88 | > 89 | 90 | 91 |