├── .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 |
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 |
124 | );
125 | }
126 | ```
127 |
128 | ## 5. Add a Submit Control
129 |
130 | ```js
131 | export default function Root() {
132 | return (
133 |
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 |
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 |
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 |
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 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/stories/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import gql from 'graphql-tag';
4 | import {
5 | combineValidators,
6 | composeValidators,
7 | isRequired,
8 | isNumeric,
9 | } from 'revalidate';
10 | import { compose, withProps } from 'recompose';
11 | import FormProvider from '../src/FormProvider';
12 | import createForm from '../src/withForm';
13 | import createHydrateProvider from '../src/createHydrateProvider';
14 | import { Input } from './Inputs';
15 | import SimpleForm from './SimpleForm';
16 |
17 | function SubmitControls() {
18 | return ;
19 | }
20 |
21 | storiesOf('Forms', module)
22 | .add('Simple Example', () => {
23 | return ;
24 | })
25 | .add('Form w/ scroll to invalid key', () => {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | })
42 | .add('Hydrating Form', () => {
43 | const sampleMutation = gql`
44 | mutation($inputData: PersonInput) {
45 | createSample(inputData: $inputData)
46 | }
47 | `;
48 |
49 | const inputQuery = gql`
50 | {
51 | sampleForm @client {
52 | name
53 | age
54 | }
55 | }
56 | `;
57 |
58 | const errorsQuery = gql`
59 | {
60 | sampleFormErrors @client {
61 | name
62 | age
63 | }
64 | }
65 | `;
66 |
67 | const hydrationQuery = gql`
68 | query sample {
69 | sampleForm {
70 | name
71 | age
72 | }
73 | }
74 | `;
75 |
76 | const sampleValidator = combineValidators({
77 | name: composeValidators(isRequired)('Name'),
78 | age: composeValidators(isRequired, isNumeric)('Age'),
79 | });
80 |
81 | const Form = compose(
82 | withProps({
83 | validator: sampleValidator,
84 | }),
85 | createForm({ mutation: sampleMutation, inputQuery, errorsQuery })
86 | )(FormProvider);
87 |
88 | const HydrateProvider = createHydrateProvider({
89 | query: hydrationQuery,
90 | queryKey: 'sampleForm',
91 | });
92 |
93 | return (
94 |
95 | {(data) => {
96 | return (
97 |
117 | );
118 | }}
119 |
120 | );
121 | });
122 |
--------------------------------------------------------------------------------