├── .babelrc
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── .prettierignore
├── .prettierrc.json
├── .travis.yml
├── LICENSE
├── README.md
├── ava.config.js
├── example
├── .babelrc
├── images
│ ├── information.svg
│ └── logo.png
├── index.html
├── js
│ ├── components
│ │ ├── Field
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── Form
│ │ │ ├── index.js
│ │ │ ├── styles.js
│ │ │ └── utils.js
│ │ ├── Input
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── Layout
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── Messages
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ └── Textarea
│ │ │ ├── index.js
│ │ │ └── styles.js
│ └── index.js
├── server.mjs
└── webpack.config.js
├── helpers
├── browser-env.js
├── enzyme.js
└── puppeteer.js
├── media
└── logo.psd
├── package.json
├── rollup.config.js
├── src
├── components
│ ├── Container
│ │ ├── __tests__
│ │ │ └── index.js
│ │ └── index.js
│ ├── Field
│ │ ├── __tests__
│ │ │ └── index.js
│ │ └── index.js
│ ├── Form
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ ├── index.js.md
│ │ │ │ ├── index.js.snap
│ │ │ │ ├── utils.js.md
│ │ │ │ └── utils.js.snap
│ │ │ ├── index.js
│ │ │ └── utils.js
│ │ ├── index.js
│ │ └── utils.js
│ ├── Messages
│ │ ├── __tests__
│ │ │ └── index.js
│ │ └── index.js
│ └── Store
│ │ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── index.js.md
│ │ │ └── index.js.snap
│ │ └── index.js
│ │ └── index.js
├── helpers
│ └── parse
│ │ ├── django
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ ├── index.js.md
│ │ │ │ └── index.js.snap
│ │ │ └── index.js
│ │ └── index.js
│ │ └── index.js
├── index.js
└── utils
│ ├── __tests__
│ ├── __snapshots__
│ │ ├── useMap.js.md
│ │ └── useMap.js.snap
│ └── useMap.js
│ ├── feedback.js
│ └── useMap.js
├── tests
├── index.js
└── snapshots
│ ├── index.js.md
│ └── index.js.snap
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | [
4 | "@babel/plugin-transform-runtime",
5 | {
6 | "regenerator": true,
7 | }
8 | ]
9 | ],
10 | "presets": ["@babel/preset-env", "@babel/preset-react"]
11 | }
12 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .history/
2 | .nyc_output/
3 | dist/
4 | example/js/build.js
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": ["standard", "prettier", "plugin:react/recommended", "prettier/standard"],
4 | "plugins": ["react"],
5 | "rules": {
6 | "no-sequences": "off",
7 | "no-void": "off",
8 | "react/react-in-jsx-scope": "off",
9 | "react/display-name": "off"
10 | },
11 | "settings": {
12 | "react": {
13 | "version": "detect"
14 | }
15 | },
16 | "globals": { "FormData": true }
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # npm
2 | node_modules/
3 | yarn-error.log
4 |
5 | # build
6 | dist/
7 | .nyc_output/
8 | example/js/build.js
9 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .history/
2 | .nyc_output/
3 | example/
4 | media/
5 | tests/
6 | helpers/
7 | dist/example/
8 |
9 | .travis.yml
10 | .babelrc
11 | .eslintignore
12 | .eslintrc
13 | .prettierignore
14 | .prettierrc.json
15 | ava.config.js
16 | rollup.config.js
17 | yarn-error.log
18 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .history/
2 | .nyc_output/
3 | dist/
4 | example/js/build.js
5 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "printWidth": 100,
4 | "singleQuote": true,
5 | "trailingComma": "all"
6 | }
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js: stable
3 | script:
4 | - yarn
5 | - yarn test
6 | - yarn coverage
7 | - yarn build
8 |
9 | addons:
10 | chrome: stable
11 |
12 | deploy:
13 | provider: heroku
14 | api_key:
15 | secure: GngOWXaxIreuywPA/NJk7+YtD1Prev7QVhYGK/dIvZVbtoJWt34ywfR0zT4qybKc+NON/c79x5gc+nOfQiU83Ft6WYSPKYYolAg/VeSrKuo92sWUz8JxYtUDyMkYxZs1JgzKaKedSDLtTgENmlSuSSi7bMw001xrxzcplENGvnlrw8viMtpE1vPZ9Z1W0JV+byAWkXMLNNe/qNVkljGmil1fZK2naXGGtjQLgMVpYRNO/zieTtbK3FFXKrKN+wFnkWej6fIjFEMXvL8unKpVBcKor8hS4dqyq3Pdef6clP7jspWy4YIK9Y2NZbQffCB1vHnfdaME6cdSHmLOhi+3RJNykl01oPucDP7zq5VfZJnUn3sgaKcP8UZNgYjWg1ZrvYSDCNpnOHLtWG1BnuziHF8vnmsidmlJGK9yrH1xbSkMjMYeTPNqRjq2KbsYWgzOcl2tL6OVr7bxTGTeO9l8ERLK2IkIAmK9gtaHsEwk7tj6uhICEkvz9MuPAlVDEFsuJPQkK6X6bpaMKX/4+GqwC5Bv+r14uUMWIyPLdbgFSukx7kRh7ibJPHbnuJNrN5G0IWjPhTVc/n3pu6Uu1mzrd1aq9lMQqY0/jGQbupPWV3UnvKvU/JCl9nDQ51BOtDJcB2KeGz5I7bQ3V3WXzRbEIiL3GruBhFkR9WwKgrN2xZs=
16 | app: formv
17 | on:
18 | repo: Wildhoney/Formv
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Adam Timberlake
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | > React form validation using the validation native to all recent browsers. Also includes support for handling API validation messages, success messages, memoized and nested form state, and super easy styling.
4 |
5 | 
6 |
7 | 
8 |
9 | 
10 |
11 | 
12 |
13 | [](https://github.com/prettier/prettier)
14 |
15 | ---
16 |
17 | ## Contents
18 |
19 | 1. [Getting Started](#getting-started)
20 | 2. [Customising Messages](#customising-messages)
21 | 3. [Skipping Validation](#skipping-validation)
22 | 4. [JS Validation](#js-validation)
23 | 5. [API Validation](#api-validation)
24 | 6. [Success Messages](#success-messages)
25 | 7. [Managing State](#managing-state)
26 | 8. [Form Architecture](#form-architecture)
27 |
28 | ## Getting Started
29 |
30 | Formv utilises the native [form validation](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation) which is built-in to all recent browsers – as such, all validation rules are set on the relevant form fields using `required`, `pattern`, `minLength`, etc...
31 |
32 | Formv has a philosophy that it should be easy to opt-out of form validation if and when you want to use another technique in the future. That means not coupling your validation to a particular method, which makes it easily reversible – that is why Formv comes with only one fundamental React component – `Form`.
33 |
34 | To get started you need to append the form to the DOM. Formv's `Form` component is a plain `form` element that intercepts the `onSubmit` function. We then nest all of our input fields in the `Form` component as you would normally. `Form` takes an optional function child as a function to pass the form's state – you can also use the context API for more complex forms.
35 |
36 | In the examples below we'll take a simple form that requires a name, email and an age. We'll add the front-end validation, capture any back-end validation errors, and show a success message when everything has been submitted.
37 |
38 | ```jsx
39 | import { Form, Messages } from 'formv';
40 |
41 | export default function MyForm() {
42 | return (
43 |
64 | );
65 | }
66 | ```
67 |
68 | **Note:** `isDirty` is an opt-in with the `withDirtyCheck` prop as it causes form data comparisons upon form change.
69 |
70 | Voila! Using the above code you have everything you need to validate your form. By clicking the `button` all validation rules will be checked, and if you've not filled in the required fields then you'll see a message appear next to the relevant `input` fields. We are also using the `isSubmitting` to determine when the form is in the process of being submitted – including any async API requests, and also a dirty check to determine if the form data has been modified from its original state.
71 |
72 | ## Customising Messages
73 |
74 | It's good and well relying on the native validation, but if you were to look in different browsers, each validation message would read slightly differently – which is awful for applications that strive for consistency! In those cases the `Form` component accepts a `messages` which is a map of [the `ValidityState` object](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState). By using the `messages` prop we can provide consistent messages across all browsers.
75 |
76 | ```jsx
77 | import { Form, Messages } from 'formv';
78 |
79 | const messages = {
80 | name: { valueMissing: 'Please enter your first and last name.' },
81 | email: {
82 | valueMissing: 'Please enter your email address.',
83 | typeMismatch: 'Please enter a valid email address.',
84 | },
85 | age: {
86 | valueMissing: 'Please enter your age.',
87 | rangeUnderflow: 'You must be 18 or over to use this form.',
88 | },
89 | };
90 |
91 | ;
92 | ```
93 |
94 | Now when you submit the form in Chrome, Firefox, Edge, Safari, Opera, etc... you will note that all of the messages appear the same – you're no longer allowing the browser to control the content of the validation messages.
95 |
96 | ## Skipping Validation
97 |
98 | There are _some_ instances where skipping validation might be beneficial. Although you can't skip the back-end validation from `Formv` directly — you should configure your back-end to accept an optional param that skips validation — it's quite easy to skip the front-end validation by introducing another `button` element with the native [`formnovalidate` attribute](https://www.w3schools.com/jsref/prop_form_novalidate.asp).
99 |
100 | In the above form if you were to add another button alongside our existing `button`, you can have one `button` that runs the front-end validation in its entirety, and another `button` that skips it altogether.
101 |
102 | ```jsx
103 |
104 |
105 | ```
106 |
107 | Interestingly when you tap `enter` in a form, the first `button` in the DOM hierarchy will be the button that's used to submit the form; in the above case `enter` would run the validation. However if you were to reverse the order of the buttons in the DOM, the `enter` key will submit the form **without** the front-end validation.
108 |
109 | ## JS Validation
110 |
111 | For the most part the native HTML validation is sufficient for our forms, especially when you consider the power that the [`pattern` attribute](https://www.w3schools.com/tags/att_input_pattern.asp) provides with regular expression based validation. Nevertheless there will **always** be edge-cases where HTML validation doesn't quite cut the mustard. In those cases `Formv` provides the `Error.Validation` and `Error.Generic` exceptions that you can raise during the `onSubmitting` phase.
112 |
113 | ```jsx
114 | import { Form, Error } from 'formv';
115 | import * as utils from './utils';
116 |
117 | const handleSubmitting = () => {
118 |
119 | if (!utils.passesQuirkyValidation(state)) {
120 | throw new Error.Validation({
121 | name: 'Does not pass our quirky validation rules.'
122 | });
123 | }
124 |
125 | });
126 |
127 |
128 | ```
129 |
130 | It's worth noting that any errors that are thrown from the `onSubmitting` handler will be merged with the native HTML validation messages.
131 |
132 | ## API Validation
133 |
134 | It's all good and well having the front-end validation for your forms, however there are always cases where the front-end validation passes just fine, whereas the back-end throws a validation error – maybe the username is already taken, for instance. In those cases we need to feed the API validation messages back into the `Form` component by using the `Error.Validation` exception that Formv exports.
135 |
136 | The validation messages need to be flattened and should map to your field names – for cases where you have an array of fields, we recommend you name these `names.0.firstName`, `names.1.firstName`, etc... Note that we have a flattening helper for Django Rest Framework (DRF) under `parse.django.flatten`.
137 |
138 | Continuing from the above example, we'll implement the `handleSubmitted` function which handles the submitting of the data to the API.
139 |
140 | ```jsx
141 | import { Form, Error } from 'formv';
142 |
143 | async function handleSubmitted() {
144 | try {
145 | await api.post('/send', data);
146 | } catch (error) {
147 | const isBadRequest = error.response.status === 400;
148 | if (isBadRequest) throw new Error.Validation(error.response.data);
149 | throw error;
150 | }
151 | }
152 |
153 | ;
154 | ```
155 |
156 | In the example above we're capturing all API errors – we then check if the status code is a `400` which indicates a validation error in our application, and then feeds the validation errors back into `Formv`. The param passed to the `Error.Validation` should be a map of errors that correspond to the `name` attributes in your fields – we will then show the messages next to the relevant fields – for instance the `error.response.data` may be the following from the back-end if we were to hard-code it on the front-end.
157 |
158 | ```javascript
159 | throw new Error.Validation({
160 | name: 'Please enter your first and last name.',
161 | age: 'You must be 18 or over to use this form.',
162 | });
163 | ```
164 |
165 | However there may be another error code that indicates a more generic error, such as that we weren't able to validate the user at this present moment – perhaps there's an error in our back-end code somewhere. In those cases you can instead raise a `Error.Generic` to provide helpful feedback to the user.
166 |
167 | ```jsx
168 | import { Form, Error } from 'formv';
169 |
170 | async function handleSubmitted() {
171 | try {
172 | await api.post('/send', data);
173 | } catch (error) {
174 | const isBadRequest = error.response.status === 400;
175 | const isAxiosError = error.isAxiosError;
176 |
177 | if (isBadRequest) throw new Error.Validation(error.response.data);
178 | if (isAxiosError) throw new Error.Generic(error.response.data);
179 | throw error;
180 | }
181 | }
182 |
183 | ;
184 | ```
185 |
186 | Using the above example we throw `Error.Validation` errors when the request yields a `400` error message, we raise a `Error.Generic` error when the error is Axios specific. Any other errors are re-thrown for capturing elsewhere, as they're likely to indicate non-request specific errors such as syntax errors and non-defined variables.
187 |
188 | ## Success Messages
189 |
190 | With all the talk of validation errors and generic errors, it may have slipped your mind that sometimes forms submit successfully! In your `onSubmitted` callback all you need to do is instantiate `Success` with the content set to some kind of success message.
191 |
192 | ```jsx
193 | import { Form, Success, Error } from 'formv';
194 |
195 | async function handleSubmitted() {
196 | try {
197 | await api.post('/send', data);
198 | return new Success('Everything went swimmingly!');
199 | } catch (error) {
200 | const isBadRequest = error.response.status === 400;
201 | const isAxiosError = error.isAxiosError;
202 |
203 | if (isBadRequest) throw new Error.Validation(error.response.data);
204 | if (isAxiosError) throw new Error.Generic(error.response.data);
205 | throw error;
206 | }
207 | }
208 |
209 | ;
210 | ```
211 |
212 | ## Managing State
213 |
214 | Managing the state for your forms is not typically an arduous task, nevertheless there are techniques that can make everything just a little bit easier, which is why `Formv` exports a `useMap` hook that has the same interface as [`react-use`'s `useMap` hook](https://github.com/streamich/react-use/blob/master/docs/useMap.md) with a handful of differences – currying, memoization and nested properties.
215 |
216 | ```javascript
217 | import { useMap } from 'formv';
218 |
219 | const [state, { set }] = useMap({
220 | username: null,
221 | profile: {
222 | age: null,
223 | created: null,
224 | },
225 | });
226 | ```
227 |
228 | Using the `set` function provided by `useMap` you can use a curried function to pass to your `Input` component. Interestingly if you use the approach below rather than creating a new function every time, the `set('username')` will never change, and as such makes everything a whole lot easier when it comes to wrapping your `Input` field in [`memo`](https://reactjs.org/docs/react-api.html#reactmemo).
229 |
230 | ```jsx
231 |
232 |
233 |
234 | ```
235 |
236 | In contrast, if you were to use the non-curried version – which works perfectly fine and is illustrated below – each time the component is rendered you'd be creating a new function which would cause the component to re-render even when it didn't need to. In an ideal scenario the component would only re-render when its value was modified.
237 |
238 | ```jsx
239 | set('username', target.value)} />
240 | ```
241 |
242 | You'll also notice that nested objects are handled with the dot notation thanks to [`Lodash`](https://lodash.com/).
243 |
244 | ## Form Architecture
245 |
246 | When deciding on an architecture for your forms, it's recommended to think about them as three separate layers. The first and most simple layer is the field which handles logic pertaining to an individual input field; it can maintain its own state such as a country selector maintains its state for a list of countries, but it does **not** maintain state for its value. Secondly there is the fieldset layer which composes many field components and is again stateless. Last of all there is the parent form component which maintains state for the entire form.
247 |
248 | With the above architecture it allows your field and fieldset components to be used freely in any form components without any quirky state management. The form field has the ultimate responsibility of maintaining and submitting the data.
249 |
250 | Using `Formv` it's easy to have the aforementioned setup as illustrated below.
251 |
252 | ```jsx
253 | import * as fv from 'formv';
254 |
255 | function Form() {
256 | const [state, { set }] = fv.useMap({
257 | name: null,
258 | age: null,
259 | });
260 |
261 | return (
262 |
263 | {({ feedback }) => (
264 | <>
265 |
266 |
267 |
268 |
269 | >
270 | )}
271 |
272 | );
273 | }
274 | ```
275 |
276 | ```jsx
277 | function Fieldset({ onChange }) {
278 | return (
279 | <>
280 |
281 |
282 | >
283 | );
284 | }
285 | ```
286 |
287 | ```jsx
288 | import * as fv from 'formv';
289 |
290 | function FieldName({ onChange }) {
291 | const { feedback } = fv.useState();
292 |
293 | return (
294 | <>
295 | onChange(target.value)} />
296 |
297 | >
298 | );
299 | }
300 | ```
301 |
302 | ```jsx
303 | import * as fv from 'formv';
304 |
305 | function FieldAge({ onChange }) {
306 | const { feedback } = fv.useState();
307 |
308 | return (
309 | <>
310 | onChange(target.value)} />
311 |
312 | >
313 | );
314 | }
315 | ```
316 |
--------------------------------------------------------------------------------
/ava.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | failFast: true,
3 | require: [
4 | '@babel/register',
5 | '@babel/polyfill',
6 | './helpers/enzyme.js',
7 | './helpers/browser-env.js',
8 | ],
9 | };
10 |
--------------------------------------------------------------------------------
/example/.babelrc:
--------------------------------------------------------------------------------
1 | { "presets": ["@babel/preset-env", "@babel/preset-react"] }
2 |
--------------------------------------------------------------------------------
/example/images/information.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
50 |
--------------------------------------------------------------------------------
/example/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildhoney/Formv/b2d4946346f33c3b4cea62117767f26cb6934b06/example/images/logo.png
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Formv Example
8 |
9 |
13 |
14 |
15 |
16 |
17 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/example/js/components/Field/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import * as fv from 'formv';
4 | import * as e from './styles';
5 |
6 | export default function Field({ children, ...props }) {
7 | return (
8 |
9 | {children}
10 |
11 | );
12 | }
13 |
14 | Field.propTypes = { children: PropTypes.node.isRequired };
15 |
--------------------------------------------------------------------------------
/example/js/components/Field/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Container = styled.div`
4 | display: grid;
5 | grid-auto-flow: row;
6 | border: 1px solid lightgray;
7 | `;
8 |
--------------------------------------------------------------------------------
/example/js/components/Form/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import * as fv from 'formv';
4 | import Input from '../Input';
5 | import Textarea from '../Textarea';
6 | import Field from '../Field';
7 | import Messages from '../Messages';
8 | import * as e from './styles';
9 | import * as utils from './utils';
10 |
11 | export default function Form({ ...props }) {
12 | const [state, { set, reset }] = fv.useMap({
13 | name: '',
14 | email: '',
15 | message: '',
16 | });
17 |
18 | return (
19 |
25 | {({ isSubmitting, feedback }) => (
26 | <>
27 |
28 |
29 |
30 |
31 |
32 |
33 |
42 |
43 |
44 |
45 |
46 |
55 |
56 |
57 |
58 |
59 |
67 |
68 |
69 |
70 |
71 |
72 | Reset
73 |
74 |
75 |
76 | {isSubmitting ? <>Submitting…> : 'Submit'}
77 |
78 |
79 |
80 | >
81 | )}
82 |
83 | );
84 | }
85 |
86 | Form.propTypes = {
87 | onSubmitting: PropTypes.func.isRequired,
88 | onSubmitted: PropTypes.func.isRequired,
89 | };
90 |
--------------------------------------------------------------------------------
/example/js/components/Form/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Container = styled.div`
4 | display: grid;
5 | grid-auto-flow: row;
6 | grid-gap: 20px;
7 | `;
8 |
9 | export const Buttons = styled.div`
10 | display: grid;
11 | grid-template-columns: 1fr 2fr;
12 | grid-gap: 20px;
13 | `;
14 |
15 | export const Button = styled.button`
16 | padding: 10px;
17 | cursor: pointer;
18 | background-color: lightgray;
19 | outline: none;
20 | border: 1px outset #fefefe;
21 |
22 | &:disabled {
23 | cursor: not-allowed;
24 | }
25 |
26 | &[type='submit'] {
27 | font-weight: bold;
28 | }
29 | `;
30 |
--------------------------------------------------------------------------------
/example/js/components/Form/utils.js:
--------------------------------------------------------------------------------
1 | export function getMessages() {
2 | return {
3 | name: {
4 | valueMissing: 'Please enter your first name.',
5 | },
6 | email: {
7 | valueMissing: 'Please enter your email address.',
8 | typeMismatch: 'Please enter a valid email address.',
9 | },
10 | message: {
11 | valueMissing: 'Please enter your message.',
12 | },
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/example/js/components/Input/index.js:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react';
2 | import PropTypes from 'prop-types';
3 | import * as e from './styles';
4 |
5 | function Input(props) {
6 | return props.onChange(target.value)} />;
7 | }
8 |
9 | Input.propTypes = { onChange: PropTypes.func.isRequired };
10 |
11 | export default memo(Input);
12 |
--------------------------------------------------------------------------------
/example/js/components/Input/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Input = styled.input`
4 | height: 40px;
5 | padding: 0 10px;
6 | outline: none;
7 | border: none;
8 | border-radius: 0;
9 | font-size: 1rem;
10 |
11 | &:read-only {
12 | color: darkgray;
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/example/js/components/Layout/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from 'react';
2 | import delay from 'delay';
3 | import * as fv from 'formv';
4 | import Form from '../Form';
5 | import * as e from './styles';
6 |
7 | export default function Layout() {
8 | const [mockGenericErrors, setMockGenericErrors] = useState(false);
9 | const [mockValidationErrors, setMockValidationErrors] = useState(false);
10 |
11 | const handleSubmitting = useCallback((state) => () => {
12 | if (state.name.toLowerCase().trim() === 'bot')
13 | throw new fv.Error.Validation({
14 | name: 'Bots are not allowed to send messages.',
15 | });
16 | });
17 |
18 | const handleSubmitted = useCallback(async () => {
19 | await delay(2500);
20 |
21 | if (mockGenericErrors) {
22 | throw new fv.Error.Generic(
23 | 'An expected error occurred when pretending to send your message.',
24 | );
25 | }
26 |
27 | if (mockValidationErrors) {
28 | throw new fv.Error.Validation({
29 | email:
30 | 'We were unable to validate the supplied e-mail address. Please try again later.',
31 | });
32 | }
33 |
34 | return new fv.Success('We have successfully pretended to send your message!');
35 | });
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | Try and submit the form below to see how the validation is handled by Formv using
43 | the browser's native validation capabilities.
44 |
45 |
46 |
47 |
48 | {!mockValidationErrors && (
49 | <>
50 | You can even mock API validation errors by{' '}
51 | setMockValidationErrors(true)}
54 | >
55 | enabling
56 | {' '}
57 | them which will feed validation errors back into the form when the form
58 | passes browser validation.
59 | >
60 | )}
61 | {mockValidationErrors && (
62 | <>
63 | You can enable a successful form submission by{' '}
64 | setMockValidationErrors(false)}
67 | >
68 | disabling
69 | {' '}
70 | mock API errors which will cause the form to submit when it passes
71 | browser validation.
72 | >
73 | )}{' '}
74 | Formv also supports handling generic messages which you can{' '}
75 | setMockGenericErrors(!mockGenericErrors)}
78 | >
79 | {mockGenericErrors ? 'disable' : 'enable'}
80 |
81 | .
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/example/js/components/Layout/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Container = styled.div`
4 | max-width: 700px;
5 | padding: 20px;
6 | display: grid;
7 | grid-gap: 20px;
8 | `;
9 |
10 | export const Image = styled.img`
11 | width: 100px;
12 | `;
13 |
14 | export const Text = styled.p`
15 | font-size: 1em;
16 | margin: 0;
17 | `;
18 |
19 | export const Anchor = styled.a`
20 | text-decoration: underline;
21 | cursor: pointer;
22 | `;
23 |
24 | export const Information = styled.div`
25 | background: url('/images/information.svg') no-repeat 10px center;
26 | background-size: 20px;
27 | padding-left: 50px;
28 | font-size: 0.75em;
29 | color: #666;
30 | `;
31 |
--------------------------------------------------------------------------------
/example/js/components/Messages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import * as fv from 'formv';
4 | import * as e from './styles';
5 |
6 | export default function Messages({ type, value }) {
7 | if (!value) return null;
8 |
9 | switch (type) {
10 | case 'success':
11 | return (
12 |
13 | {value}
14 |
15 | );
16 |
17 | case 'generic':
18 | return (
19 |
20 | {value}
21 |
22 | );
23 |
24 | case 'validation':
25 | return (
26 |
27 | {value.map((message) => (
28 |
{message}
29 | ))}
30 |
31 | );
32 | }
33 | }
34 |
35 | Messages.propTypes = {
36 | type: PropTypes.string.isRequired,
37 | value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]),
38 | };
39 |
40 | Messages.defaultProps = {
41 | value: null,
42 | };
43 |
--------------------------------------------------------------------------------
/example/js/components/Messages/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Success = styled.p.attrs({ className: 'formv-message' })`
4 | background-color: #e5f9e5;
5 | padding: 20px;
6 | color: #002800;
7 | margin: 0;
8 | `;
9 |
10 | export const GenericError = styled.p.attrs({ className: 'formv-message' })`
11 | background-color: #ffeded;
12 | padding: 20px;
13 | color: #4c1616;
14 | margin: 0;
15 | `;
16 |
17 | export const ValidationError = styled.ul.attrs({ className: 'formv-messages' })`
18 | margin: 0;
19 | padding: 10px 10px 10px 30px;
20 | list-style-type: square;
21 | border-top: 0;
22 | background-color: white;
23 | font-size: 0.75em;
24 | color: #ff4c4c;
25 | `;
26 |
--------------------------------------------------------------------------------
/example/js/components/Textarea/index.js:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react';
2 | import PropTypes from 'prop-types';
3 | import * as e from './styles';
4 |
5 | function Textarea(props) {
6 | return props.onChange(target.value)} />;
7 | }
8 |
9 | Textarea.propTypes = { onChange: PropTypes.func.isRequired };
10 |
11 | export default memo(Textarea);
12 |
--------------------------------------------------------------------------------
/example/js/components/Textarea/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Textarea = styled.textarea`
4 | padding: 10px;
5 | outline: none;
6 | min-height: 150px;
7 | border: none;
8 | resize: none;
9 | border-radius: 0;
10 | font-size: 1rem;
11 |
12 | &:read-only {
13 | color: darkgray;
14 | }
15 | `;
16 |
--------------------------------------------------------------------------------
/example/js/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import { createGlobalStyle } from 'styled-components';
4 | import Layout from './components/Layout';
5 |
6 | export const Styles = createGlobalStyle`
7 | body {
8 | font-family: Lato, Arial, Helvetica, sans-serif;
9 | min-height: 100vh;
10 | padding: 0;
11 | margin: 0;
12 | display: flex;
13 | place-items: center;
14 | place-content: center;
15 | }
16 | `;
17 |
18 | document.addEventListener('DOMContentLoaded', () => {
19 | const node = document.querySelector('*[data-app]');
20 | render(
21 | <>
22 |
23 |
24 | >,
25 | node,
26 | );
27 | });
28 |
--------------------------------------------------------------------------------
/example/server.mjs:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import http from 'http';
3 | import express from 'express';
4 |
5 | const app = express();
6 | const server = http.createServer(app);
7 |
8 | app.use(express.static(path.resolve('./example')));
9 |
10 | const port = process.env.PORT || 3000;
11 | server.listen(port);
12 |
--------------------------------------------------------------------------------
/example/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | entry: ['core-js', '@babel/polyfill', path.resolve('./example/js/index.js')],
5 | mode: 'development',
6 | output: {
7 | filename: 'build.js',
8 | path: path.resolve('./example/js'),
9 | libraryTarget: 'var',
10 | },
11 | resolve: {
12 | alias: {
13 | formv: path.resolve('./src'),
14 | },
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.js$/,
20 | exclude: /node_modules/,
21 | use: {
22 | loader: 'babel-loader',
23 | },
24 | },
25 | ],
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/helpers/browser-env.js:
--------------------------------------------------------------------------------
1 | import browserEnv from 'browser-env';
2 |
3 | browserEnv();
4 |
--------------------------------------------------------------------------------
/helpers/enzyme.js:
--------------------------------------------------------------------------------
1 | import Enzyme from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | Enzyme.configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/helpers/puppeteer.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer';
2 | import getPort from 'get-port';
3 | import execa from 'execa';
4 | import delay from 'delay';
5 |
6 | export function withPage(debug = false) {
7 | return async (t, run) => {
8 | const port = await getPort();
9 | const browser = await puppeteer.launch({
10 | headless: !debug,
11 | devtools: debug,
12 | defaultViewport: null,
13 | });
14 | const page = await browser.newPage();
15 |
16 | execa('yarn', ['start'], {
17 | env: {
18 | PORT: port,
19 | },
20 | });
21 |
22 | try {
23 | await delay(2000);
24 | await page.goto(`http://0.0.0.0:${port}`, {
25 | waitUntil: 'load',
26 | });
27 | await run(t, page);
28 | } catch (error) {
29 | console.error(`Puppeteer: ${error}.`);
30 | t.fail();
31 | } finally {
32 | await page.close();
33 | await browser.close();
34 | }
35 | };
36 | }
37 |
38 | export function getHelpers(page) {
39 | async function getSuccessMessage() {
40 | await page.waitFor('.formv-message');
41 | return page.$eval('.formv-message', (node) => node.innerHTML);
42 | }
43 |
44 | function getValidationMessages() {
45 | return page.$$eval('.formv-messages li', (nodes) => {
46 | return Array.from(nodes).map((node) => node.innerHTML);
47 | });
48 | }
49 |
50 | async function getGenericMessages() {
51 | await page.waitFor('.formv-message');
52 | return page.$eval('.formv-message', (node) => node.innerHTML);
53 | }
54 |
55 | async function includesClassName(selector, className) {
56 | return page.evaluate(
57 | ({ selector, className }) => {
58 | const node = document.querySelector(selector);
59 | return node.classList.contains(className);
60 | },
61 | { selector, className },
62 | );
63 | }
64 |
65 | return { getGenericMessages, getSuccessMessage, getValidationMessages, includesClassName };
66 | }
67 |
--------------------------------------------------------------------------------
/media/logo.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildhoney/Formv/b2d4946346f33c3b4cea62117767f26cb6934b06/media/logo.psd
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "formv",
3 | "version": "5.4.0",
4 | "description": "React form validation using the validation native to all recent browsers. Also includes support for handling API validation messages, success messages, memoized and nested form state, and super easy styling.",
5 | "main": "dist/formv.cjs.js",
6 | "module": "dist/formv.esm.js",
7 | "repository": "git@github.com:Wildhoney/Formv.git",
8 | "author": "Adam Timberlake ",
9 | "license": "MIT",
10 | "scripts": {
11 | "build": "rollup -c && webpack --config example/webpack.config.js",
12 | "lint": "eslint '**/**.js'",
13 | "test": "yarn lint && yarn build && ava --verbose",
14 | "spec": "yarn build && ava",
15 | "coverage": "nyc ava './src/**/*.js' && nyc report --reporter=text-lcov | coveralls",
16 | "format": "NODE_ENV=production prettier --write '**/**.{js,html,md}'",
17 | "start": "node --experimental-modules example/server.mjs",
18 | "dev": "concurrently 'webpack --config example/webpack.config.js --watch' 'node --experimental-modules example/server.mjs'"
19 | },
20 | "peerDependencies": {
21 | "prop-types": "^15.0.0",
22 | "react": "^16.8.0",
23 | "react-dom": "^16.8.0"
24 | },
25 | "devDependencies": {
26 | "@babel/core": "^7.10.4",
27 | "@babel/plugin-transform-runtime": "^7.10.4",
28 | "@babel/polyfill": "^7.10.4",
29 | "@babel/preset-env": "^7.10.4",
30 | "@babel/preset-react": "^7.10.4",
31 | "@babel/register": "^7.10.4",
32 | "@babel/runtime": "^7.10.4",
33 | "ava": "^3.9.0",
34 | "babel-eslint": "^10.1.0",
35 | "babel-loader": "^8.1.0",
36 | "browser-env": "^3.3.0",
37 | "concurrently": "^5.2.0",
38 | "core-js": "^3.6.5",
39 | "coveralls": "^3.1.0",
40 | "delay": "^4.3.0",
41 | "enzyme": "^3.11.0",
42 | "enzyme-adapter-react-16": "^1.15.2",
43 | "eslint": "^7.4.0",
44 | "eslint-config-prettier": "^6.11.0",
45 | "eslint-config-standard": "^14.1.1",
46 | "eslint-plugin-import": "^2.22.0",
47 | "eslint-plugin-node": "^11.1.0",
48 | "eslint-plugin-promise": "^4.2.1",
49 | "eslint-plugin-react": "^7.20.3",
50 | "eslint-plugin-standard": "^4.0.1",
51 | "execa": "^4.0.2",
52 | "express": "^4.17.1",
53 | "get-port": "^5.1.1",
54 | "nyc": "^15.1.0",
55 | "prettier": "^2.0.5",
56 | "prop-types": "^15.7.2",
57 | "puppeteer": "^5.0.0",
58 | "react": "^16.13.1",
59 | "react-dom": "^16.13.1",
60 | "react-use": "^15.3.2",
61 | "rollup": "^2.18.2",
62 | "rollup-plugin-babel": "^4.4.0",
63 | "rollup-plugin-commonjs": "^10.1.0",
64 | "rollup-plugin-node-resolve": "^5.2.0",
65 | "rollup-plugin-terser": "^6.1.0",
66 | "sinon": "^9.0.2",
67 | "starwars": "^1.0.1",
68 | "styled-components": "^5.1.1",
69 | "webpack": "^4.43.0",
70 | "webpack-cli": "^3.3.12"
71 | },
72 | "dependencies": {
73 | "lodash": "^4.17.15",
74 | "ramda": "^0.27.0",
75 | "react-tracked": "^1.4.1"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from 'rollup-plugin-node-resolve';
2 | import commonjs from 'rollup-plugin-commonjs';
3 | import babel from 'rollup-plugin-babel';
4 | import { terser } from 'rollup-plugin-terser';
5 |
6 | module.exports = {
7 | input: 'src/index.js',
8 | external: ['react', 'react-dom', 'prop-types'],
9 | output: [
10 | {
11 | file: 'dist/formv.cjs.js',
12 | format: 'cjs',
13 | sourcemap: true,
14 | exports: 'named',
15 | },
16 | {
17 | file: 'dist/formv.esm.js',
18 | format: 'esm',
19 | sourcemap: true,
20 | exports: 'named',
21 | },
22 | ],
23 | plugins: [
24 | resolve({ preferBuiltins: true }),
25 | babel({
26 | exclude: 'node_modules/**',
27 | runtimeHelpers: true,
28 | }),
29 | commonjs({
30 | namedExports: {
31 | include: 'node_modules/**',
32 | 'node_modules/react/index.js': [
33 | 'cloneElement',
34 | 'createContext',
35 | 'Component',
36 | 'createElement',
37 | 'useRef',
38 | 'useState',
39 | 'useCallback',
40 | 'useContext',
41 | 'useEffect',
42 | ],
43 | 'node_modules/react-is/index.js': ['isElement', 'isValidElementType', 'ForwardRef'],
44 | 'node_modules/styled-components/dist/styled-components.esm.js': ['createContext'],
45 | 'node_modules/react-tracked/src/index.js': ['memo', 'getUntrackedObject'],
46 | },
47 | }),
48 | terser(),
49 | ],
50 | };
51 |
--------------------------------------------------------------------------------
/src/components/Container/__tests__/index.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { mount } from 'enzyme';
3 | import React from 'react';
4 | import { useTrackedState } from '../../Store';
5 | import Container from '../';
6 |
7 | test('It should be able to set the default value of dirty check if enabled;', (t) => {
8 | t.plan(2);
9 |
10 | function Example() {
11 | const state = useTrackedState();
12 | t.false(state.isDirty);
13 | return null;
14 | }
15 |
16 | mount(
17 |
18 |
19 | ,
20 | );
21 | });
22 |
23 | test('It should be able to set the default value of dirty check if disabled;', (t) => {
24 | t.plan(2);
25 |
26 | function Example() {
27 | const state = useTrackedState();
28 | t.is(state.isDirty, null);
29 | return null;
30 | }
31 |
32 | mount(
33 |
34 |
35 | ,
36 | );
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/Container/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { ensuredForwardRef } from 'react-use';
4 | import Store from '../Store';
5 | import Form from '../Form';
6 |
7 | const Container = ensuredForwardRef((props, ref) => {
8 | return (
9 |
10 |
11 |
12 | );
13 | });
14 |
15 | Container.propTypes = { withDirtyCheck: PropTypes.bool };
16 |
17 | Container.defaultProps = { withDirtyCheck: false };
18 |
19 | export default Container;
20 |
--------------------------------------------------------------------------------
/src/components/Field/__tests__/index.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { mount } from 'enzyme';
3 | import React, { useRef } from 'react';
4 | import { useMount } from 'react-use';
5 | import { useTrackedState } from '../../Store';
6 | import Container from '../../Container';
7 | import Field from '../';
8 |
9 | test('It should be able to set the data attribute of the unique ID for the highest function to use;', (t) => {
10 | t.plan(1);
11 |
12 | function Example() {
13 | const ref = useRef();
14 | const state = useTrackedState();
15 |
16 | useMount(() => t.is(ref.current.dataset.id, state.meta.id));
17 |
18 | return (
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | mount(
26 |
27 |
28 | ,
29 | );
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/Field/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { ensuredForwardRef } from 'react-use';
4 | import { useTrackedState } from '../Store';
5 |
6 | const Field = ensuredForwardRef(({ children }, ref) => {
7 | const state = useTrackedState();
8 |
9 | return (
10 |
13 | );
14 | });
15 |
16 | Field.propTypes = { children: PropTypes.node.isRequired };
17 |
18 | export default Field;
19 |
--------------------------------------------------------------------------------
/src/components/Form/__tests__/__snapshots__/index.js.md:
--------------------------------------------------------------------------------
1 | # Snapshot report for `src/components/Form/__tests__/index.js`
2 |
3 | The actual snapshot is saved in `index.js.snap`.
4 |
5 | Generated by [AVA](https://avajs.dev).
6 |
7 | ## It should be able to handle both standard children and function as children;
8 |
9 | > Snapshot 1
10 |
11 | ''
12 |
13 | > Snapshot 2
14 |
15 | ''
16 |
17 | > Snapshot 3
18 |
19 | ``
33 |
--------------------------------------------------------------------------------
/src/components/Form/__tests__/__snapshots__/index.js.snap:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildhoney/Formv/b2d4946346f33c3b4cea62117767f26cb6934b06/src/components/Form/__tests__/__snapshots__/index.js.snap
--------------------------------------------------------------------------------
/src/components/Form/__tests__/__snapshots__/utils.js.md:
--------------------------------------------------------------------------------
1 | # Snapshot report for `src/components/Form/__tests__/utils.js`
2 |
3 | The actual snapshot is saved in `utils.js.snap`.
4 |
5 | Generated by [AVA](https://avajs.dev).
6 |
7 | ## It should be able to merge all of the validation messages togther;
8 |
9 | > Snapshot 1
10 |
11 | {
12 | first: [
13 | 'Bots are not allowed to post messages.',
14 | ],
15 | fourth: [
16 | 'Constraints not satisfied',
17 | ],
18 | second: [
19 | 'Constraints not satisfied',
20 | 'Please enter your name, sunshine!',
21 | ],
22 | third: [
23 | 'Bots are still not allowed to post messages.',
24 | 'We are entirely certain that bots cannot post messages!',
25 | ],
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Form/__tests__/__snapshots__/utils.js.snap:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildhoney/Formv/b2d4946346f33c3b4cea62117767f26cb6934b06/src/components/Form/__tests__/__snapshots__/utils.js.snap
--------------------------------------------------------------------------------
/src/components/Form/__tests__/index.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { mount } from 'enzyme';
3 | import React from 'react';
4 | import delay from 'delay';
5 | import sinon from 'sinon';
6 | import Form from '../../Container';
7 | import { actions } from '../../Store';
8 | import * as utils from '../../Form/utils';
9 |
10 | test('It should be able to handle the onChange event;', (t) => {
11 | const spies = { onChange: sinon.spy() };
12 | sinon.stub(actions, 'dirtyCheck').callThrough();
13 |
14 | const wrapper = mount();
15 | const form = wrapper.find('form');
16 | form.simulate('change');
17 |
18 | t.is(spies.onChange.callCount, 1);
19 | t.is(actions.dirtyCheck.callCount, 1);
20 | t.true(actions.dirtyCheck.calledWith({ current: form.getDOMNode() }, sinon.match.array));
21 | actions.dirtyCheck.restore();
22 | });
23 |
24 | test('It should be able to handle the onClick event;', (t) => {
25 | const spies = { onClick: sinon.spy() };
26 | sinon.stub(utils, 'isSubmitButton').callThrough();
27 |
28 | const wrapper = mount();
29 | wrapper.find('form').simulate('click');
30 |
31 | t.is(spies.onClick.callCount, 1);
32 | t.is(utils.isSubmitButton.callCount, 1);
33 | t.true(utils.isSubmitButton.calledWith(sinon.match.instanceOf(global.HTMLElement)));
34 | utils.isSubmitButton.restore();
35 | });
36 |
37 | test('It should be able to handle the onReset event;', (t) => {
38 | const spies = { onReset: sinon.spy() };
39 | sinon.stub(actions, 'reset').callThrough();
40 |
41 | const wrapper = mount();
42 | const form = wrapper.find('form');
43 | form.simulate('reset');
44 |
45 | t.is(spies.onReset.callCount, 1);
46 | t.is(actions.reset.callCount, 1);
47 | t.true(actions.reset.calledWith({ current: form.getDOMNode() }));
48 | actions.reset.restore();
49 | });
50 |
51 | test('It should be able to handle the onSubmit event;', async (t) => {
52 | const spies = {
53 | onSubmitting: sinon.spy(),
54 | onSubmitted: sinon.spy(),
55 | event: { preventDefault: sinon.spy() },
56 | };
57 |
58 | sinon.stub(utils, 'submitForm').callThrough();
59 | sinon.stub(actions, 'submitting').callThrough();
60 | sinon.stub(actions, 'submitted').callThrough();
61 |
62 | const wrapper = mount(
63 | ,
64 | );
65 | const form = wrapper.find('form');
66 | form.simulate('submit', spies.event);
67 |
68 | await delay(1);
69 |
70 | t.is(spies.onSubmitting.callCount, 1);
71 | t.is(spies.onSubmitted.callCount, 1);
72 | t.is(spies.event.preventDefault.callCount, 1);
73 |
74 | t.is(utils.submitForm.callCount, 1);
75 | t.is(actions.submitting.callCount, 1);
76 | t.is(actions.submitted.callCount, 1);
77 |
78 | t.true(utils.submitForm.calledWith(sinon.match.object));
79 | t.true(actions.submitted.calledWith(sinon.match.object));
80 |
81 | utils.submitForm.restore();
82 | actions.submitting.restore();
83 | actions.submitted.restore();
84 | });
85 |
86 | test('It should be able to invoke the initialise function on mount;', (t) => {
87 | sinon.stub(actions, 'initialise').callThrough();
88 |
89 | const wrapper = mount();
90 | const form = wrapper.find('form');
91 | form.simulate('reset');
92 |
93 | t.is(actions.initialise.callCount, 1);
94 | t.true(actions.initialise.calledWith({ current: form.getDOMNode() }));
95 | });
96 |
97 | test('It should be able to handle both standard children and function as children;', (t) => {
98 | const wrapper = mount(
99 | ,
102 | );
103 | const html = wrapper.html();
104 | t.snapshot(html);
105 |
106 | {
107 | const wrapper = mount();
108 | t.is(wrapper.html(), html);
109 | t.snapshot(wrapper.html());
110 | }
111 |
112 | {
113 | const wrapper = mount(
114 | ,
115 | );
116 | t.snapshot(wrapper.html());
117 | }
118 | });
119 |
--------------------------------------------------------------------------------
/src/components/Form/__tests__/utils.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import sinon from 'sinon';
3 | import { Error } from '../../../';
4 | import { FormvSuccess, FormvValidationError } from '../../../utils/feedback';
5 | import * as utils from '../utils';
6 |
7 | test('It should be able to determine if a button is a form submit button;', (t) => {
8 | t.false(utils.isSubmitButton(document.createElement('div')));
9 |
10 | const input = document.createElement('input');
11 | t.false(utils.isSubmitButton(input));
12 | input.setAttribute('type', 'submit');
13 | t.true(utils.isSubmitButton(input));
14 |
15 | const button = document.createElement('button');
16 | button.setAttribute('type', 'reset');
17 | t.false(utils.isSubmitButton(button));
18 | button.removeAttribute('type');
19 | t.true(utils.isSubmitButton(button));
20 | button.setAttribute('type', 'submit');
21 | t.true(utils.isSubmitButton(button));
22 | });
23 |
24 | test('It should be able to determine if the exception is related;', (t) => {
25 | const syntaxError = new SyntaxError();
26 | const genericError = new Error.Generic();
27 | const validationError = new Error.Validation();
28 |
29 | t.false(utils.isRelatedException(syntaxError));
30 | t.true(utils.isRelatedException(genericError));
31 | t.true(utils.isRelatedException(validationError));
32 | });
33 |
34 | test('It should be able to yield a collection of the invalid form fields;', (t) => {
35 | const form = document.createElement('form');
36 | const first = document.createElement('input');
37 | const second = document.createElement('input');
38 | const third = document.createElement('input');
39 |
40 | first.setAttribute('name', 'first');
41 | second.setAttribute('name', 'second');
42 | third.setAttribute('name', 'third');
43 |
44 | form.append(first);
45 | form.append(second);
46 | form.append(third);
47 |
48 | t.deepEqual(
49 | utils.collateInvalidFields(form, {
50 | first: 'invalid',
51 | third: 'invalid',
52 | }),
53 | [first, third],
54 | );
55 | });
56 |
57 | test('It should be able to retrieve the actual form field from the cloned form field;', (t) => {
58 | const form = document.createElement('form');
59 | const field = document.createElement('input');
60 | field.setAttribute('name', 'example');
61 | form.append(field);
62 |
63 | const clonedForm = form.cloneNode(true);
64 | const clonedField = clonedForm.querySelector('input');
65 | const getField = utils.fromClone(form);
66 |
67 | t.is(getField(clonedField), field);
68 | });
69 |
70 | test('It should be able to determine when the passed type is a function;', (t) => {
71 | const a = () => {};
72 | const b = 'x';
73 | const c = {};
74 |
75 | t.true(utils.isFunction(a));
76 | t.false(utils.isFunction(b));
77 | t.false(utils.isFunction(c));
78 | });
79 |
80 | test('It should be able to merge all of the validation messages togther;', (t) => {
81 | const form = document.createElement('form');
82 | const first = document.createElement('input');
83 | const second = document.createElement('input');
84 | const third = document.createElement('input');
85 | const fourth = document.createElement('input');
86 |
87 | first.setAttribute('name', 'first');
88 | second.setAttribute('name', 'second');
89 | third.setAttribute('name', 'third');
90 | fourth.setAttribute('name', 'fourth');
91 |
92 | first.setAttribute('required', '');
93 | second.setAttribute('required', '');
94 | third.setAttribute('required', '');
95 | fourth.setAttribute('required', '');
96 |
97 | form.append(first);
98 | form.append(second);
99 | form.append(third);
100 | form.append(fourth);
101 |
102 | const messages = utils.mergeValidationMessages(
103 | [...form.elements],
104 | [
105 | {
106 | first: {
107 | valueMissing: 'Bots are not allowed to post messages.',
108 | },
109 | third: {
110 | valueMissing: [
111 | 'Bots are still not allowed to post messages.',
112 | 'We are entirely certain that bots cannot post messages!',
113 | ],
114 | },
115 | },
116 | {
117 | second: 'Please enter your name, sunshine!',
118 | },
119 | ],
120 | );
121 |
122 | t.snapshot(messages);
123 | });
124 |
125 | test('It should be able to determine the key that failed validation;', (t) => {
126 | const field = document.createElement('input');
127 | field.setAttribute('name', 'first');
128 | field.setAttribute('required', '');
129 |
130 | t.is(utils.getValidationKey(field), 'valueMissing');
131 | });
132 |
133 | test('It should be able to map over any given items and remove duplicates;', (t) => {
134 | const xs = [1, 1, 2, 3, 4, 5, 5, 6, 7, 8, 9, 9, 9, 10];
135 | t.deepEqual(xs.filter(utils.isUnique), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
136 | });
137 |
138 | test('It should be able to obtain an array of the form data for dirty comparisons', (t) => {
139 | const form = document.createElement('form');
140 | const first = document.createElement('input');
141 | const second = document.createElement('input');
142 | const third = document.createElement('input');
143 |
144 | first.setAttribute('name', 'name');
145 | second.setAttribute('name', 'email');
146 | third.setAttribute('name', 'location');
147 |
148 | first.setAttribute('value', 'Adam');
149 | second.setAttribute('value', 'adam.timberlake@gmail.com');
150 | third.setAttribute('value', 'Watford, UK');
151 |
152 | form.append(first);
153 | form.append(second);
154 | form.append(third);
155 |
156 | t.deepEqual(utils.getFormData(form), [
157 | 'name',
158 | 'email',
159 | 'location',
160 | 'Adam',
161 | 'adam.timberlake@gmail.com',
162 | 'Watford, UK',
163 | ]);
164 | });
165 |
166 | test('It should be able to determine the highest fieldset in the form is;', (t) => {
167 | const fields = [
168 | document.createElement('input'),
169 | document.createElement('input'),
170 | document.createElement('input'),
171 | ];
172 |
173 | const fieldsets = [
174 | document.createElement('fieldset'),
175 | document.createElement('fieldset'),
176 | document.createElement('fieldset'),
177 | ];
178 |
179 | fieldsets.forEach((fieldset, index) => {
180 | fieldset.append(fields[index]);
181 | fieldset.setAttribute('data-id', 'abc');
182 | });
183 |
184 | fieldsets[0].getBoundingClientRect = () => ({ top: 55 });
185 | fieldsets[1].getBoundingClientRect = () => ({ top: 25 });
186 | fieldsets[2].getBoundingClientRect = () => ({ top: 165 });
187 |
188 | t.is(utils.getHighestFieldset(fields, 'abc'), fieldsets[1]);
189 | t.is(utils.getHighestFieldset(fields, 'abcdef'), null);
190 | });
191 |
192 | test('It should be able to handle successful form submissions;', async (t) => {
193 | const spies = {
194 | onSubmitting: sinon.spy(),
195 | onSubmitted: sinon.spy(() => new FormvSuccess('Congrats!')),
196 | onInvalid: sinon.spy(),
197 | };
198 |
199 | const form = document.createElement('form');
200 | const first = document.createElement('input');
201 | const second = document.createElement('input');
202 | const third = document.createElement('input');
203 |
204 | first.setAttribute('name', 'name');
205 | second.setAttribute('name', 'email');
206 | third.setAttribute('name', 'location');
207 |
208 | first.setAttribute('value', 'Adam');
209 | second.setAttribute('value', 'adam.timberlake@gmail.com');
210 | third.setAttribute('value', 'Watford, UK');
211 |
212 | form.append(first);
213 | form.append(second);
214 | form.append(third);
215 |
216 | const result = await utils.submitForm({
217 | form: { current: form },
218 | button: { current: null },
219 | messages: {},
220 | event: {},
221 | id: 'abc',
222 | onInvalid: spies.onInvalid,
223 | onSubmitting: spies.onSubmitting,
224 | onSubmitted: spies.onSubmitted,
225 | });
226 |
227 | t.is(spies.onSubmitting.callCount, 1);
228 | t.is(spies.onSubmitted.callCount, 1);
229 | t.is(spies.onInvalid.callCount, 0);
230 |
231 | t.deepEqual(result, {
232 | isValid: true,
233 | isDirty: false,
234 | meta: {
235 | fields: [],
236 | data: ['name', 'email', 'location', 'Adam', 'adam.timberlake@gmail.com', 'Watford, UK'],
237 | highest: null,
238 | },
239 | feedback: { success: 'Congrats!', errors: [], field: {} },
240 | });
241 | });
242 |
243 | test('It should be able to skip the validation if the button has "formnovalidate";', async (t) => {
244 | const spies = {
245 | onSubmitting: sinon.spy(),
246 | onSubmitted: sinon.spy(),
247 | onInvalid: sinon.spy(),
248 | };
249 |
250 | const form = document.createElement('form');
251 | const field = document.createElement('input');
252 | const button = document.createElement('button');
253 |
254 | field.setAttribute('name', 'name');
255 | field.setAttribute('value', '');
256 | field.setAttribute('required', '');
257 | button.setAttribute('formnovalidate', '');
258 | form.append(field);
259 | form.append(button);
260 |
261 | const result = await utils.submitForm({
262 | form: { current: form },
263 | button: { current: button },
264 | messages: {},
265 | event: {},
266 | id: 'abc',
267 | onSubmitting: spies.onSubmitting,
268 | onSubmitted: spies.onSubmitted,
269 | onInvalid: spies.onInvalid,
270 | });
271 |
272 | t.is(spies.onSubmitting.callCount, 1);
273 | t.is(spies.onSubmitted.callCount, 1);
274 | t.is(spies.onInvalid.callCount, 0);
275 |
276 | t.deepEqual(result, {
277 | isValid: true,
278 | isDirty: false,
279 | meta: {
280 | fields: [],
281 | data: ['name', ''],
282 | highest: null,
283 | },
284 | feedback: { success: null, errors: [], field: {} },
285 | });
286 | });
287 |
288 | test('It should be able to throw validation errors if the validation fails;', async (t) => {
289 | const spies = {
290 | onSubmitting: sinon.spy(),
291 | onSubmitted: sinon.spy(),
292 | onInvalid: sinon.spy(),
293 | };
294 |
295 | const form = document.createElement('form');
296 | const field = document.createElement('input');
297 |
298 | field.setAttribute('name', 'name');
299 | field.setAttribute('value', '');
300 | field.setAttribute('required', '');
301 | form.append(field);
302 |
303 | const result = await utils.submitForm({
304 | form: { current: form },
305 | button: { current: null },
306 | messages: {},
307 | event: {},
308 | id: 'abc',
309 | onSubmitting: spies.onSubmitting,
310 | onSubmitted: spies.onSubmitted,
311 | onInvalid: spies.onInvalid,
312 | });
313 |
314 | t.is(spies.onSubmitting.callCount, 1);
315 | t.is(spies.onSubmitted.callCount, 0);
316 | t.is(spies.onInvalid.callCount, 1);
317 |
318 | t.deepEqual(result, {
319 | feedback: {
320 | errors: null,
321 | field: {
322 | name: ['Constraints not satisfied'],
323 | },
324 | success: null,
325 | },
326 | isValid: false,
327 | meta: {
328 | data: null,
329 | fields: [field],
330 | highest: null,
331 | },
332 | });
333 | });
334 |
335 | test('It should be able to throw validation errors if the API fails;', async (t) => {
336 | const spies = {
337 | onSubmitting: sinon.spy(),
338 | onSubmitted: sinon.spy(() => {
339 | throw new FormvValidationError({
340 | name: 'Bots are definitely not allowed to be posting.',
341 | });
342 | }),
343 | onInvalid: sinon.spy(),
344 | };
345 |
346 | const form = document.createElement('form');
347 | const field = document.createElement('input');
348 |
349 | field.setAttribute('name', 'name');
350 | field.setAttribute('value', 'Bot!');
351 | field.setAttribute('required', '');
352 | form.append(field);
353 |
354 | const result = await utils.submitForm({
355 | form: { current: form },
356 | button: { current: null },
357 | messages: {},
358 | event: {},
359 | id: 'abc',
360 | onSubmitting: spies.onSubmitting,
361 | onSubmitted: spies.onSubmitted,
362 | onInvalid: spies.onInvalid,
363 | });
364 |
365 | t.is(spies.onSubmitting.callCount, 1);
366 | t.is(spies.onSubmitted.callCount, 1);
367 | t.is(spies.onInvalid.callCount, 1);
368 |
369 | t.deepEqual(result, {
370 | feedback: {
371 | errors: null,
372 | field: {
373 | name: ['Bots are definitely not allowed to be posting.'],
374 | },
375 | success: null,
376 | },
377 | isValid: false,
378 | meta: {
379 | data: null,
380 | fields: [field],
381 | highest: null,
382 | },
383 | });
384 | });
385 |
386 | test('It should be able to re-throw errors that are not related to Formv;', async (t) => {
387 | const spies = {
388 | onSubmitting: sinon.spy(() => {
389 | throw new global.Error('Uh oh!');
390 | }),
391 | onSubmitted: sinon.spy(),
392 | onInvalid: sinon.spy(),
393 | };
394 |
395 | const form = document.createElement('form');
396 | const field = document.createElement('input');
397 |
398 | field.setAttribute('name', 'name');
399 | field.setAttribute('value', 'Adam');
400 | field.setAttribute('required', '');
401 | form.append(field);
402 |
403 | await t.throwsAsync(
404 | async () =>
405 | utils.submitForm({
406 | form: { current: form },
407 | button: { current: null },
408 | messages: {},
409 | event: {},
410 | id: 'abc',
411 | onSubmitting: spies.onSubmitting,
412 | onSubmitted: spies.onSubmitted,
413 | onInvalid: spies.onInvalid,
414 | }),
415 | { instanceOf: global.Error, message: 'Uh oh!' },
416 | );
417 |
418 | t.is(spies.onSubmitting.callCount, 1);
419 | t.is(spies.onSubmitted.callCount, 0);
420 | t.is(spies.onInvalid.callCount, 0);
421 | });
422 |
--------------------------------------------------------------------------------
/src/components/Form/index.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef, useEffect } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { useMountedState, useMount, useIsomorphicLayoutEffect, ensuredForwardRef } from 'react-use';
4 | import { identity } from 'ramda';
5 | import { useTracked, actions } from '../Store';
6 | import * as utils from './utils';
7 |
8 | const Form = ensuredForwardRef(
9 | (
10 | {
11 | messages,
12 | withDirtyCheck,
13 | withScroll,
14 | onClick,
15 | onChange,
16 | onReset,
17 | onUpdate,
18 | onInvalid,
19 | onSubmitting,
20 | onSubmitted,
21 | children,
22 | ...props
23 | },
24 | form,
25 | ) => {
26 | const button = useRef();
27 | const isMounted = useMountedState();
28 | const [state, dispatch] = useTracked();
29 |
30 | useMount(() => {
31 | // Set the current state of the form on DOM load, and the initial state of the form data.
32 | dispatch(actions.initialise(form));
33 | });
34 |
35 | useEffect(() => {
36 | // Useful callback for rare occasions where you need the state in a parent above the component.
37 | // Such as when the form has an ID and the input's have form attributes to connect them to the form.
38 | onUpdate(state);
39 | }, [state]);
40 |
41 | useIsomorphicLayoutEffect(() => {
42 | withScroll &&
43 | state.meta.highest &&
44 | state.meta.highest.firstChild.scrollIntoView({
45 | block: 'start',
46 | behavior: 'smooth',
47 | });
48 | }, [state.meta.active, state.meta.highest, withScroll]);
49 |
50 | const handleSubmitting = useCallback(
51 | async (event) => {
52 | event.preventDefault();
53 | dispatch(actions.submitting());
54 |
55 | const validityState = await utils.submitForm({
56 | form,
57 | button,
58 | messages,
59 | event,
60 | id: state.meta.id,
61 | onInvalid,
62 | onSubmitting,
63 | onSubmitted,
64 | });
65 |
66 | button.current = null;
67 | isMounted() && dispatch(actions.submitted(validityState));
68 | },
69 | [form, button, onSubmitting, onInvalid, onSubmitted],
70 | );
71 |
72 | const handleClick = useCallback(
73 | (event) => {
74 | onClick(event);
75 |
76 | // Handle the clicks on the form to determine what was used to submit the form, if any.
77 | utils.isSubmitButton(event.target) && (button.current = event.target);
78 | },
79 | [onClick],
80 | );
81 |
82 | const handleChange = useCallback(
83 | (event) => {
84 | onChange(event);
85 |
86 | withDirtyCheck &&
87 | isMounted() &&
88 | dispatch(actions.dirtyCheck(form, state.meta.data));
89 | },
90 | [state.meta.data, withDirtyCheck, onChange],
91 | );
92 |
93 | const handleReset = useCallback(
94 | (event) => {
95 | onReset(event);
96 |
97 | isMounted() && dispatch(actions.reset(form));
98 | },
99 | [form, onReset],
100 | );
101 |
102 | return (
103 |
114 | );
115 | },
116 | );
117 |
118 | Form.propTypes = {
119 | messages: PropTypes.shape(
120 | PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired,
121 | ),
122 | withScroll: PropTypes.bool,
123 | withDirtyCheck: PropTypes.bool.isRequired,
124 | children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
125 | onClick: PropTypes.func,
126 | onChange: PropTypes.func,
127 | onReset: PropTypes.func,
128 | onUpdate: PropTypes.func,
129 | onInvalid: PropTypes.func,
130 | onSubmitting: PropTypes.func,
131 | onSubmitted: PropTypes.func,
132 | };
133 |
134 | Form.defaultProps = {
135 | messages: {},
136 | withScroll: false,
137 | children: <>>,
138 | onClick: identity,
139 | onChange: identity,
140 | onReset: identity,
141 | onUpdate: identity,
142 | onInvalid: identity,
143 | onSubmitting: identity,
144 | onSubmitted: identity,
145 | };
146 |
147 | export default Form;
148 |
--------------------------------------------------------------------------------
/src/components/Form/utils.js:
--------------------------------------------------------------------------------
1 | import * as feedback from '../../utils/feedback';
2 |
3 | export async function submitForm({ button, event, ...props }) {
4 | // Clone the form in its current state, because if fields are disabled when submitting then
5 | // the validity will pass because they are not considered part of the form.
6 | const form = { current: props.form.current.cloneNode(true) };
7 |
8 | // Obtain the form data as it was submitting.
9 | const data = getFormData(form.current);
10 |
11 | // Also yield a function that will allow us to grab the actual input field from the clone.
12 | const getField = fromClone(props.form.current);
13 |
14 | // Determine if the form requires validation based on the `formnovalidate` field.
15 | const requiresValidation = !button.current || !button.current.hasAttribute('formnovalidate');
16 |
17 | try {
18 | // Invoke the `onSubmitting` callback so the user can handle the form state
19 | // change, and have an opportunity to raise early generic and/or validation
20 | // errors.
21 | await props.onSubmitting(event);
22 |
23 | // Check to see whether the validation passes the native validation, and if not
24 | // throw an empty validation error to collect the error messages directly from
25 | // each of the fields.
26 | if (requiresValidation && !form.current.checkValidity())
27 | throw new feedback.FormvValidationError({});
28 |
29 | // Finally invoke the `onSubmitted` event after passing the client-side validation. If this
30 | // invocation doesn't throw any errors, then we'll consider the submission a success.
31 | const result = await props.onSubmitted(event);
32 | const success = result instanceof feedback.FormvSuccess ? result.message : null;
33 |
34 | return {
35 | isValid: true,
36 | isDirty: false,
37 | meta: { fields: [], data, highest: null },
38 | feedback: { success, errors: [], field: {} },
39 | };
40 | } catch (error) {
41 | // We'll only re-throw errors if they are non-Formv errors.
42 | if (!isRelatedException(error)) throw error;
43 |
44 | // We always invoke the `onInvalid` callback even if the errors are not necessarily
45 | // applicable to Formv validation.
46 | props.onInvalid(event);
47 |
48 | if (error instanceof feedback.FormvGenericError) {
49 | // Feed any generic API error messages back into the component.
50 | return {
51 | isValid: false,
52 | meta: { fields: [], data: null, highest: null },
53 | feedback: { success: null, error: [].concat(error.messages).flat(), field: {} },
54 | };
55 | }
56 |
57 | if (error instanceof feedback.FormvValidationError) {
58 | // Feed the API validation errors back into the component after collating all of the
59 | // invalid field and their associated validation messages.
60 | const fields = collateInvalidFields(form.current, error.messages);
61 | const messages = mergeValidationMessages(fields, [props.messages, error.messages]);
62 | const highest = getHighestFieldset(fields.map(getField), props.id);
63 |
64 | return {
65 | isValid: false,
66 | meta: { fields: fields.map(getField), data: null, highest },
67 | feedback: { success: null, errors: null, field: messages },
68 | };
69 | }
70 | }
71 | }
72 |
73 | export function fromClone(form) {
74 | return (field) => [...form.elements].find(({ name }) => field.name === name);
75 | }
76 |
77 | export function isFunction(x) {
78 | return typeof x === 'function';
79 | }
80 |
81 | export function isSubmitButton(element) {
82 | const name = element.nodeName.toLowerCase();
83 | const type = element.getAttribute('type');
84 |
85 | if (name === 'input' && type === 'submit') return true;
86 | if (name === 'button' && (type === 'submit' || type === null)) return true;
87 | return false;
88 | }
89 |
90 | export function isRelatedException(error) {
91 | return (
92 | error instanceof feedback.FormvGenericError ||
93 | error instanceof feedback.FormvValidationError
94 | );
95 | }
96 |
97 | export function collateInvalidFields(form, messages = {}) {
98 | const keys = Object.keys(messages);
99 |
100 | return [...form.elements].filter(
101 | (element) => !element.validity.valid || keys.includes(element.name),
102 | );
103 | }
104 |
105 | export function mergeValidationMessages(fields, [customMessages = {}, exceptionMessages = {}]) {
106 | return fields.reduce((messages, field) => {
107 | const { name } = field;
108 | const key = getValidationKey(field);
109 |
110 | const feedback = {
111 | custom: customMessages[name]
112 | ? customMessages[name][key] || field.validationMessage
113 | : field.validationMessage,
114 | exception: exceptionMessages[name] || [],
115 | };
116 |
117 | return {
118 | ...messages,
119 | [name]: [...[].concat(feedback.custom), ...[].concat(feedback.exception)]
120 | .filter(Boolean)
121 | .filter(isUnique)
122 | .flat(),
123 | };
124 | }, {});
125 | }
126 |
127 | export function getValidationKey(field) {
128 | for (var key in field.validity) {
129 | const isInvalid = key !== 'valid' && field.validity[key];
130 | if (isInvalid) return key;
131 | }
132 | }
133 |
134 | export function isUnique(value, index, list) {
135 | return list.indexOf(value) === index;
136 | }
137 |
138 | export function getFormData(form) {
139 | const data = new FormData(form);
140 | return [...data.keys(), ...data.values()];
141 | }
142 |
143 | export function getHighestFieldset(fields, id) {
144 | const [node] = fields.reduce(
145 | ([hidhestNode, nodePosition], node) => {
146 | // Attempt to obtain the fieldset closest to the discovered form field, and ensure
147 | // if it's not available that the application doesn't panic.
148 | const fieldset = node.closest(`fieldset[data-id="${id}"]`);
149 | if (!fieldset) return [hidhestNode, nodePosition];
150 |
151 | // Determine the position of the fieldset in the DOM, and then calculate whether it's
152 | // positioned higher than the current highest.
153 | const position = fieldset.getBoundingClientRect();
154 |
155 | return position.top < nodePosition
156 | ? [fieldset, position.top]
157 | : [hidhestNode, nodePosition];
158 | },
159 | [null, Infinity],
160 | );
161 |
162 | return node;
163 | }
164 |
--------------------------------------------------------------------------------
/src/components/Messages/__tests__/index.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import starwars from 'starwars';
3 | import { mount } from 'enzyme';
4 | import React from 'react';
5 | import Messages from '../';
6 |
7 | test('It should be able to the messages elegantly when passing a string;', (t) => {
8 | const text = starwars();
9 | const wrapper = mount();
10 | t.is(wrapper.html(), `
`);
14 | });
15 |
16 | test('It should be able to the messages elegantly when passing an array of strings;', (t) => {
17 | const text = [starwars(), starwars()];
18 | const wrapper = mount();
19 | t.is(wrapper.html(), `