├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── __tests__ ├── Errors.test.js └── Form.test.js ├── package.json └── src ├── Errors.js ├── Form.js ├── index.js └── util ├── fieldNameValidation.js ├── formData.js ├── index.js └── objects.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" 4 | ], 5 | "plugins": [ 6 | "transform-object-rest-spread", 7 | "transform-async-to-generator" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [{package.json,*.scss,*.css}] 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | node_modules 3 | npm-debug.log 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __tests__ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | public/ 2 | vendor/ 3 | resources/assets/js/back/redactor/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'stable' 4 | script: 5 | - npm run test 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `form-backend-validation` will be documented in this file 4 | 5 | ## 2.4.0 - 2020-06-04 6 | 7 | - added `form.errors.any` method 8 | 9 | ## 2.3.9 - 2019-10-21 10 | 11 | - convert `null` values to emptu strings when using FormData 12 | 13 | ## 2.3.8 - 2019-07-25 14 | 15 | - convert boolean values to 1 or 0 when using FormData 16 | 17 | ## 2.3.7 - 2019-01-10 18 | 19 | - fix omitted empty values on submit 20 | 21 | ## 2.3.6 - 2018-01-03 22 | - Fixed: Build script 23 | 24 | ## 2.3.5 - 2018-12-26 25 | - Fixed: Better support for arrays of objects 26 | 27 | ## 2.3.4 - 2018-12-13 28 | - Changed: Checks now for the existence of window before trying to access axios of it 29 | 30 | ## 2.3.3 - 2018-03-27 31 | - Changed: Updated errors are now mutated instead of reassigned (fixes reactivity caveats) 32 | 33 | ## 2.3.2 - 2018-03-02 34 | - Fixed: Build script 35 | 36 | ## 2.3.1 - 2018-02-05 37 | - Fixed: Multi file uploads 38 | 39 | ## 2.3.0 - 2018-01-29 40 | - Added: Support for file uploads 41 | - Added: Support for array notation in `Errors.clear` 42 | 43 | ## 2.2.0 - 2018-01-20 44 | - Added: `only` method 45 | 46 | ## 2.1.0 - 2017-11-21 47 | - Added: `successful` state property 48 | - Added: `populate` method to fill the form with values without overwriting the initial values 49 | - Fixed: A bug that would mutate initial values when using arrays 50 | 51 | ## 2.0.5 - 2017-11-13 52 | - Fixed: Republished because 2.0.4 changes didn't come through 53 | 54 | ## 2.0.4 - 2017-11-13 55 | - Changed: Look for `axios` on `window` as the http client 56 | 57 | ## 2.0.3 - 2017-11-06 58 | - Fixed: Removed unnecessary Vue dependency 59 | 60 | ## 2.0.2 - 2017-10-19 61 | - Fixed: A bug that would mutate initial values when using nested objects 62 | 63 | ## 2.0.1 - 2017-09-05 64 | - Fixed: Errors for an array of fields are now passed to the Form errors 65 | 66 | ## 2.0.0 - 2017-08-30 67 | - Added: Static `create` method, and fluent `withData`, `withOptions`, `withErrors` methods 68 | - Added: `onSuccess` and `onFail` can now be set by passing them as an option to the form 69 | - Changed: `Errors.get` has been renamed to `Errors.first`, `get` now returns all of a field's errors 70 | - Changed: `Form.getError` is equivalent to `Errors.first`, `Form.getErrors` to `Errors.get` 71 | - Changed: Responses must follow Laravel 5.5's error response format (see https://laravel.com/docs/5.5/upgrade) 72 | - Changed: `onFail` and `catch` on request promises now return a full error object instead of the response data 73 | 74 | ## 1.8.0 - 2017-08-23 75 | - Added: `setInitialValues` 76 | 77 | 78 | ## 1.7.0 - 2017-08-16 79 | - Added: You can now use your own http library by passing an `http` option 80 | - Changed: Axios is now an optional dependency 81 | 82 | ## 1.6.0 - 2017-07-18 83 | - Added: `getError` method on `Form` 84 | 85 | ## 1.5.1 - 2017-07-17 86 | - Added: Can now import `Errors` directly as a separate module 87 | - Added: `Errors` can now accept an object of errors 88 | 89 | ## 1.5.0 - 2017-05-19 90 | - Added: `options` parameter to `Form`. Currently accepts a `resetOnSuccess` option 91 | 92 | ## 1.4.1 - 2017-04-26 93 | - Changed: Axios is now a peer dependency to avoid multiple installed versions. Add axios to your project with `npm install axios --save` or `yarn add axios` 94 | 95 | ## 1.4.0 - 2017-03-12 96 | - Added: Support for errors that are returned as string 97 | 98 | ## 1.3.0 - 2017-03-09 99 | - Added: `errors.has` and `errors.clear` 100 | 101 | ## 1.2.0 - 2017-01-31 102 | - Added: Processing property 103 | 104 | ## 1.1.1 - 2017-01-30 105 | - Added: Clear all errors when submitting the form 106 | 107 | ## 1.1.0 - 2017-01-18 108 | - Added: `clear` method on `Form` 109 | - Fixed: Make `reset` method on `Form` respect default values 110 | 111 | ## 1.0.0 - 2017-01-16 112 | - Initial release 113 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [](https://supportukrainenow.org) 3 | 4 | # An easy way to validate forms using back end logic 5 | 6 | [![Latest Version on NPM](https://img.shields.io/npm/v/form-backend-validation.svg?style=flat-square)](https://npmjs.com/package/form-backend-validation) 7 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 8 | [![Build Status](https://img.shields.io/travis/spatie/form-backend-validation/master.svg?style=flat-square)](https://travis-ci.org/spatie/form-backend-validation) 9 | [![npm](https://img.shields.io/npm/dt/form-backend-validation.svg?style=flat-square)](https://npmjs.com/package/form-backend-validation) 10 | 11 | Wouldn't it be great if you could just use your back end to validate forms on the front end? This package provides a `Form` class that does exactly that. It can post itself to a configured endpoint and manage errors. The class is meant to be used with a Laravel back end. 12 | 13 | Take a look at the [usage section](#usage) to view a detailed example on how to use it. 14 | 15 | The code of this package is based on the [Object-Oriented Forms lesson](https://laracasts.com/series/learn-vue-2-step-by-step/episodes/19) in the [Vue 2.0 series](https://laracasts.com/series/learn-vue-2-step-by-step/) on [Laracasts](https://laracasts.com/). 16 | 17 | ## Support us 18 | 19 | [](https://spatie.be/github-ad-click/form-backend-validation) 20 | 21 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 22 | 23 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 24 | 25 | ## Install 26 | 27 | You can install the package via yarn (or npm): 28 | 29 | ```bash 30 | yarn add form-backend-validation 31 | ``` 32 | 33 | By default, this package expects `axios` to be installed (unless you're using your own http library, see the [Options](#options) section for that). 34 | 35 | ```bash 36 | yarn add axios 37 | ``` 38 | 39 | ## Usage 40 | 41 | You can find an example implementation with Laravel and Vue in the [spatie/form-backend-validation-example-app repo](https://github.com/spatie/form-backend-validation-example-app). 42 | 43 | ![Screenshot](https://raw.githubusercontent.com/spatie/form-backend-validation-example-app/master/public/images/screenshot.png) 44 | 45 | ```js 46 | import Form from 'form-backend-validation'; 47 | 48 | // Instantiate a form class with some values 49 | const form = new Form({ 50 | field1: 'value 1', 51 | field2: 'value 2', 52 | person: { 53 | first_name: 'John', 54 | last_name: 'Doe', 55 | }, 56 | }); 57 | 58 | // A form can also be initiated with an array 59 | const form = new Form(['field1', 'field2']); 60 | 61 | // Submit the form, you can also use `.put`, `.patch` and `.delete` 62 | form.post(anUrl) 63 | .then(response => ...) 64 | .catch(response => ...); 65 | 66 | // Returns true if request is being executed 67 | form.processing; 68 | 69 | // If there were any validation errors, you easily access them 70 | 71 | // Example error response (json) 72 | { 73 | "errors": { 74 | "field1": ['Value is required'], 75 | "field2": ['Value is required'] 76 | } 77 | } 78 | 79 | // Returns an object in which the keys are the field names 80 | // and the values array with error message sent by the server 81 | form.errors.all(); 82 | 83 | // Returns true if there were any error 84 | form.errors.any(); 85 | 86 | // Returns object with errors for the specified keys in array. 87 | form.errors.any(keys); 88 | 89 | // Returns true if there is an error for the given field name or object 90 | form.errors.has(key); 91 | 92 | // Returns the first error for the given field name 93 | form.errors.first(key); 94 | 95 | // Returns an array with errors for the given field name 96 | form.errors.get(key); 97 | 98 | // Shortcut for getting the first error for the given field name 99 | form.getError(key); 100 | 101 | // Clear all errors 102 | form.errors.clear(); 103 | 104 | // Clear the error of the given field name or all errors on the given object 105 | form.errors.clear(key); 106 | 107 | // Returns an object containing fields based on the given array of field names 108 | form.only(keys); 109 | 110 | // Reset the values of the form to those passed to the constructor 111 | form.reset(); 112 | 113 | // Set the values which should be used when calling reset() 114 | form.setInitialValues(); 115 | 116 | // Populate a form after its instantiation, the populated fields will override the initial fields 117 | // Fields not present at instantiation will not be populated 118 | const form = new Form({ 119 | field1: '', 120 | field2: '', 121 | }); 122 | 123 | form.populate({ 124 | field1: 'foo', 125 | field2: 'bar', 126 | }); 127 | 128 | ``` 129 | 130 | ### Options 131 | 132 | The `Form` class accepts a second `options` parameter. 133 | 134 | ```js 135 | const form = new Form({ 136 | field1: 'value 1', 137 | field2: 'value 2', 138 | }, { 139 | resetOnSuccess: false, 140 | }); 141 | ``` 142 | 143 | You can also pass options via a `withOptions` method (this example uses the `create` factory method. 144 | 145 | ``` 146 | const form = Form.create() 147 | .withOptions({ resetOnSuccess: false }) 148 | .withData({ 149 | field1: 'value 1', 150 | field2: 'value 2', 151 | }); 152 | ``` 153 | 154 | #### `resetOnSuccess: bool` 155 | 156 | Default: `true`. Set to `false` if you don't want the form to reset to its original values after a succesful submit. 157 | 158 | #### `http: Object` 159 | 160 | By default this library uses `axios` for http request. If you want, you can roll with your own http library (or your own axios instance). 161 | 162 | *Advanced!* Pass a custom http library object. Your http library needs to adhere to the following interface for any http method you're using: 163 | 164 | ```ts 165 | method(url: string, data: Object): Promise 166 | ``` 167 | 168 | Supported http methods are `get`, `delete`, `head`, `post`, `put` & `patch`. 169 | 170 | If you want to see how the http library is used internally, refer to the `Form` class' `submit` method. 171 | 172 | ### Working with files 173 | 174 | The form handles file inputs too. The data is then sent as `FormData`, which means it's encoded as `multipart/form-data`. 175 | 176 | Some frameworks (like Laravel, Symfony) can't handle these incoming requests through other methods than `POST`, so you might need to take measures to work around this limitation. In Laravel or Symfony, that would mean adding a hidden `_method` field to your form containing the desired HTTP verb. 177 | 178 | ## Changelog 179 | 180 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 181 | 182 | ## Testing 183 | 184 | ``` bash 185 | $ npm run test 186 | ``` 187 | 188 | ## Contributing 189 | 190 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 191 | 192 | ## Security 193 | 194 | If you discover any security related issues, please contact [Freek Van der Herten](https://github.com/freekmurze) instead of using the issue tracker. 195 | 196 | ## Postcardware 197 | 198 | You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 199 | 200 | Our address is: Spatie, Kruikstraat 22, 2018 Antwerp, Belgium. 201 | 202 | We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards). 203 | 204 | ## Credits 205 | 206 | - [Freek Van der Herten](https://github.com/freekmurze) 207 | - [Sebastian De Deyne](https://github.com/sebastiandedeyne) 208 | - [All Contributors](../../contributors) 209 | 210 | Initial code of this package was copied from [Jeffrey Way](https://twitter.com/jeffrey_way)'s [Vue-Forms repo](https://github.com/laracasts/Vue-Forms/). 211 | 212 | The idea to go about this way of validating forms comes from [Laravel Spark](https://spark.laravel.com/). 213 | 214 | ## License 215 | 216 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 217 | -------------------------------------------------------------------------------- /__tests__/Errors.test.js: -------------------------------------------------------------------------------- 1 | import Errors from '../src/Errors'; 2 | 3 | let errors; 4 | 5 | describe('Errors', () => { 6 | beforeEach(() => { 7 | errors = new Errors(); 8 | }); 9 | 10 | it('can determine if there are any errors', () => { 11 | expect(errors.any()).toBe(false); 12 | 13 | errors.record({ first_name: ['Value is required'] }); 14 | 15 | expect(errors.any()).toBe(true); 16 | }); 17 | 18 | it('can determine if a given field or object has any errors', () => { 19 | expect(errors.any()).toBe(false); 20 | 21 | errors.record({ 22 | first_name: ['Value is required'], 23 | 'person.0.first_name': ['Value is required'], 24 | }); 25 | 26 | expect(errors.has('first')).toBe(false); 27 | expect(errors.has('first_name')).toBe(true); 28 | expect(errors.has('person')).toBe(true); 29 | }); 30 | 31 | it('can get all errors', () => { 32 | const allErrors = { first_name: ['Value is required'] }; 33 | 34 | errors.record(allErrors); 35 | 36 | expect(errors.all()).toEqual(allErrors); 37 | }); 38 | 39 | it('can get a specific error', () => { 40 | expect(errors.any()).toBe(false); 41 | 42 | errors.record({ first_name: ['Value is required'] }); 43 | 44 | expect(errors.first('first_name')).toEqual('Value is required'); 45 | 46 | expect(errors.first('last_name')).toBeUndefined(); 47 | }); 48 | 49 | it('can clear all the errors', () => { 50 | errors.record({ 51 | first_name: ['Value is required'], 52 | last_name: ['Value is required'], 53 | }); 54 | 55 | expect(errors.any()).toBe(true); 56 | 57 | errors.clear(); 58 | 59 | expect(errors.any()).toBe(false); 60 | }); 61 | 62 | it('can clear a specific error', () => { 63 | errors.record({ 64 | first_name: ['Value is required'], 65 | last_name: ['Value is required'], 66 | }); 67 | 68 | errors.clear('first_name'); 69 | 70 | expect(errors.has('first_name')).toBe(false); 71 | expect(errors.has('last_name')).toBe(true); 72 | }); 73 | 74 | it('can clear all errors of a given object', () => { 75 | errors.record({ 76 | 'person.first_name': ['Value is required'], 77 | 'person.last_name': ['Value is required'], 78 | 'dates.0.start_date': ['Value is required'], 79 | 'dates.1.start_date': ['Value is required'], 80 | 'roles[0].name': ['Value is required'], 81 | 'roles[1].name': ['Value is required'], 82 | }); 83 | 84 | errors.clear('person'); 85 | errors.clear('dates.0'); 86 | errors.clear('roles[1]'); 87 | 88 | expect(errors.has('person')).toBe(false); 89 | expect(errors.has('person.first_name')).toBe(false); 90 | expect(errors.has('person.last_name')).toBe(false); 91 | 92 | expect(errors.has('dates')).toBe(true); 93 | expect(errors.has('dates.0.start_date')).toBe(false); 94 | expect(errors.has('dates.1.start_date')).toBe(true); 95 | 96 | expect(errors.has('roles')).toBe(true); 97 | expect(errors.has('roles[0].name')).toBe(true); 98 | expect(errors.has('roles[1].name')).toBe(false); 99 | }); 100 | 101 | it('can accept an object of errors in its constructor', () => { 102 | errors = new Errors({ 103 | first_name: ['Value is required'], 104 | }); 105 | 106 | expect(errors.first('first_name')).toEqual('Value is required'); 107 | }); 108 | 109 | it('can assign an empty object in its constructor if no errors are passed', () => { 110 | errors = new Errors(); 111 | 112 | expect(errors.all()).toEqual({}); 113 | }); 114 | 115 | it('can pass array of keys to any method and get back error of specified key', () => { 116 | errors = new Errors({ 117 | 'first_name': ['This field is required'], 118 | 'last_name': ['This field is required'], 119 | 'age': ['This field is required'], 120 | }); 121 | 122 | expect(errors.any(['first_name', 'last_name'])).toEqual({ 123 | 'first_name': ['This field is required'], 124 | 'last_name': ['This field is required'], 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /__tests__/Form.test.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Form from '../src/Form'; 3 | import MockAdapter from 'axios-mock-adapter'; 4 | import { reservedFieldNames } from '../src/util'; 5 | 6 | let form; 7 | let mockAdapter; 8 | 9 | describe('Form', () => { 10 | beforeEach(() => { 11 | form = new Form({ 12 | field1: 'value 1', 13 | field2: 'value 2', 14 | }); 15 | 16 | mockAdapter = new MockAdapter(axios); 17 | }); 18 | 19 | it('is initializable', () => { 20 | form = new Form({}, {}); 21 | form = Form.create({}); 22 | }); 23 | 24 | it('exposes the passed form field values as properties', () => { 25 | expect(form.field1).toBe('value 1'); 26 | expect(form.field2).toBe('value 2'); 27 | }); 28 | 29 | it('remembers initial values in the `initial` property', () => { 30 | form.field1 = 'changed'; 31 | 32 | expect(form.field1).toBe('changed'); 33 | expect(form.initial.field1).toBe('value 1'); 34 | }); 35 | 36 | it('should only return the specified fields', () => { 37 | const filtered = form.only(['field2']); 38 | 39 | expect(filtered).toEqual({ field2: 'value 2' }); 40 | }); 41 | 42 | it('can reset the form values', () => { 43 | form.field1 = 'changed'; 44 | form.field2 = 'changed'; 45 | 46 | form.reset(); 47 | 48 | expect(form.field1).toBe('value 1'); 49 | expect(form.field2).toBe('value 2'); 50 | }); 51 | 52 | it('can use the current data as the new initial values', () => { 53 | form.setInitialValues({ field1: 'new initial' }); 54 | 55 | form.reset(); 56 | 57 | expect(form.field1).toBe('new initial'); 58 | }); 59 | 60 | it('uses a copy for initial values to avoid mutation', () => { 61 | form = new Form({ address: { street: 'Samberstraat' } }); 62 | 63 | form.address.street = 'Langestraat'; 64 | 65 | expect(form.initial.address.street).toBe('Samberstraat'); 66 | }); 67 | 68 | it('resets with a copy for initial values to avoid object mutation', () => { 69 | form = new Form({ address: { street: 'Samberstraat' } }); 70 | 71 | form.address.street = 'Langestraat'; 72 | 73 | form.reset(); 74 | expect(form.address.street).toBe('Samberstraat'); 75 | 76 | // Assert again to ensure the values in the `reset` aren't mutable 77 | 78 | form.address.street = 'Langestraat'; 79 | 80 | form.reset(); 81 | expect(form.address.street).toBe('Samberstraat'); 82 | }); 83 | 84 | it('resets with a copy for initial values to avoid array mutation', () => { 85 | form = new Form({ jobs: ['developer'] }); 86 | 87 | form.jobs.push('designer'); 88 | 89 | form.reset(); 90 | expect(form.jobs).toEqual(['developer']); 91 | 92 | // Assert again to ensure the values in the `reset` aren't mutable 93 | 94 | form.jobs.push('designer'); 95 | 96 | form.reset(); 97 | expect(form.jobs).toEqual(['developer']); 98 | }); 99 | 100 | it('can clear the form values', () => { 101 | form.clear(); 102 | 103 | expect(form.field1).toBe(''); 104 | expect(form.field2).toBe(''); 105 | }); 106 | 107 | it("can't be initialized with a reserved field name", () => { 108 | reservedFieldNames.forEach(fieldName => { 109 | expect(() => new Form({ [fieldName]: 'foo' })).toThrow(); 110 | }); 111 | }); 112 | 113 | it('can be populated with an object', () => { 114 | form = new Form({ field: '' }); 115 | 116 | form.populate({ field: 'foo' }); 117 | 118 | expect(form.field).toBe('foo'); 119 | }); 120 | 121 | it("can't be populated with fields not present during instantiation", () => { 122 | form = new Form({ field: '' }); 123 | 124 | form.populate({ field: 'foo', anotherField: 'baz' }); 125 | 126 | expect(form.anotherField).toBe(undefined); 127 | }); 128 | 129 | it("can't be populated with a reserved field name", () => { 130 | reservedFieldNames.forEach(fieldName => { 131 | expect(() => new Form().populate({ [fieldName]: 'foo' })).toThrow(); 132 | }); 133 | }); 134 | 135 | it('will record the errors that the server passes through', async () => { 136 | mockAdapter.onPost('http://example.com/posts').reply(422, { 137 | errors: { first_name: ['Value is required'] }, 138 | }); 139 | 140 | try { 141 | await form.submit('post', 'http://example.com/posts'); 142 | } catch (e) {} // eslint-disable-line no-empty 143 | 144 | expect(form.errors.has('first_name')).toBe(true); 145 | }); 146 | 147 | it('can accept an array with form field names', () => { 148 | form = new Form(['field1', 'field2']); 149 | 150 | expect(form.data()['field1']).toBe(''); 151 | expect(form.data()['field2']).toBe(''); 152 | }); 153 | 154 | it('resets the form on success unless the feature is disabled', async () => { 155 | mockAdapter.onPost('http://example.com/posts').reply(200, {}); 156 | 157 | form = new Form({ field: 'value' }); 158 | 159 | form.field = 'changed'; 160 | 161 | await form.submit('post', 'http://example.com/posts'); 162 | 163 | expect(form.field).toBe('value'); 164 | 165 | form = new Form({ field: 'value' }, { resetOnSuccess: false }); 166 | 167 | form.field = 'changed'; 168 | 169 | await form.submit('post', 'http://example.com/posts'); 170 | 171 | expect(form.field).toBe('changed'); 172 | }); 173 | 174 | it('can see if there is an error for a field', () => { 175 | expect(form.hasError('field1')).toBe(false); 176 | 177 | form.errors.record({ field1: ['Value is required'] }); 178 | 179 | expect(form.hasError('field1')).toBe(true); 180 | }); 181 | 182 | it('can be successfully completed', async () => { 183 | mockAdapter.onPost('http://example.com/posts').reply(200, {}); 184 | 185 | form = new Form(); 186 | 187 | await form.submit('post', 'http://example.com/posts'); 188 | 189 | expect(form.successful).toBe(true); 190 | }); 191 | 192 | it('can get an error message for a field', () => { 193 | form = Form.create({ field1: '', field2: '' }).withErrors({ 194 | field1: [], 195 | field2: ['Field 2 is required', 'Field 2 must be an e-mail'], 196 | }); 197 | 198 | expect(form.getError('field1')).toEqual(undefined); 199 | expect(form.getErrors('field1')).toEqual([]); 200 | 201 | expect(form.getError('field2')).toEqual('Field 2 is required'); 202 | expect(form.getErrors('field2')).toEqual([ 203 | 'Field 2 is required', 204 | 'Field 2 must be an e-mail', 205 | ]); 206 | }); 207 | 208 | it('can accept a custom http instance in options', () => { 209 | const http = axios.create({ baseURL: 'http://anotherexample.com' }); 210 | 211 | form = new Form({}, { http }); 212 | 213 | expect(form.__http.defaults.baseURL).toBe('http://anotherexample.com'); 214 | 215 | form = new Form({}); 216 | 217 | expect(form.__http.defaults.baseURL).toBe(undefined); 218 | }); 219 | 220 | it('can override onSuccess and onFail methods by passing it in options', () => { 221 | form = new Form({}, { onSuccess: () => 'foo', onFail: () => 'bar' }); 222 | 223 | expect(form.onSuccess()).toBe('foo'); 224 | expect(form.onFail()).toBe('bar'); 225 | }); 226 | 227 | it('can call directly HTTP verbs to submit', () => { 228 | form.submit = (...args) => args; 229 | 230 | expect(form.post('url')).toEqual(['post', 'url']); 231 | expect(form.put('url')).toEqual(['put', 'url']); 232 | expect(form.patch('url')).toEqual(['patch', 'url']); 233 | expect(form.delete('url')).toEqual(['delete', 'url']); 234 | }); 235 | 236 | it('transforms the data to a FormData object if there is a File', async () => { 237 | const file = new File(['hello world!'], 'myfile'); 238 | 239 | form.field1 = { 240 | foo: 'testFoo', 241 | bar: ['testBar1', 'testBar2'], 242 | baz: new Date(Date.UTC(2012, 3, 13, 2, 12)), 243 | }; 244 | form.field2 = file; 245 | 246 | mockAdapter.onPost('http://example.com/posts').reply(request => { 247 | expect(request.data).toBeInstanceOf(FormData); 248 | expect(request.data.get('field1[foo]')).toBe('testFoo'); 249 | expect(request.data.get('field1[bar][0]')).toBe('testBar1'); 250 | expect(request.data.get('field1[bar][1]')).toBe('testBar2'); 251 | expect(request.data.get('field1[baz]')).toBe('2012-04-13T02:12:00.000Z'); 252 | expect(request.data.get('field2')).toEqual(file); 253 | 254 | expect(getFormDataKeys(request.data)).toEqual([ 255 | 'field1[foo]', 256 | 'field1[bar][0]', 257 | 'field1[bar][1]', 258 | 'field1[baz]', 259 | 'field2', 260 | ]); 261 | return [200, {}]; 262 | }); 263 | 264 | await form.submit('post', 'http://example.com/posts'); 265 | }); 266 | 267 | it('transforms the data to a FormData object if there is a multiple input File', async () => { 268 | const file1 = new File(['hello world!'], 'myfile1'); 269 | const file2 = new File(['hello world!'], 'myfile2'); 270 | 271 | form.field1 = [file1, file2]; 272 | form.field1.__proto__ = Object.create(FileList.prototype); 273 | 274 | mockAdapter.onPost('http://example.com/posts').reply(request => { 275 | expect(request.data).toBeInstanceOf(FormData); 276 | expect(request.data.get('field1[0]')).toEqual(file1); 277 | expect(request.data.get('field1[1]')).toEqual(file2); 278 | expect(request.data.get('field2')).toBe('value 2'); 279 | 280 | expect(getFormDataKeys(request.data)).toEqual([ 281 | 'field1[0]', 282 | 'field1[1]', 283 | 'field2', 284 | ]); 285 | return [200, {}]; 286 | }); 287 | 288 | await form.submit('post', 'http://example.com/posts'); 289 | }); 290 | 291 | it('transforms the boolean values in FormData object to "1" or "0"', async () => { 292 | const file = new File(['hello world!'], 'myfile'); 293 | 294 | form.field1 = { 295 | foo: true, 296 | bar: false 297 | }; 298 | form.field2 = file; 299 | 300 | mockAdapter.onPost('http://example.com/posts').reply(request => { 301 | expect(request.data).toBeInstanceOf(FormData); 302 | expect(request.data.get('field1[foo]')).toBe('1'); 303 | expect(request.data.get('field1[bar]')).toBe('0'); 304 | expect(request.data.get('field2')).toEqual(file); 305 | 306 | expect(getFormDataKeys(request.data)).toEqual([ 307 | 'field1[foo]', 308 | 'field1[bar]', 309 | 'field2', 310 | ]); 311 | return [200, {}]; 312 | }); 313 | 314 | await form.submit('post', 'http://example.com/posts'); 315 | }); 316 | }); 317 | 318 | function getFormDataKeys(formData) { 319 | // This is because the FormData.keys() is missing from the jsdom implementations. 320 | return formData[Object.getOwnPropertySymbols(formData)[0]]._entries.map(e => e.name); 321 | } 322 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "form-backend-validation", 3 | "version": "2.4.0", 4 | "description": "An easy way to validate forms using back end logic", 5 | "main": "dist/index.js", 6 | "jsnext:main": "src/index.js", 7 | "scripts": { 8 | "build": "rm -rf dist/* && babel src -d dist", 9 | "test": "jest", 10 | "prepublish": "npm run test; npm run build", 11 | "format": "prettier --write \"**/*.{css,js,vue}\"" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/spatie/form-backend-validation.git" 16 | }, 17 | "keywords": [ 18 | "spatie", 19 | "vue", 20 | "laravel", 21 | "form", 22 | "validation", 23 | "server" 24 | ], 25 | "author": "Freek Van der Herten", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/spatie/form-backend-validation/issues" 29 | }, 30 | "homepage": "https://github.com/spatie/form-backend-validation", 31 | "optionalDependencies": { 32 | "axios": ">=0.15 <1.0" 33 | }, 34 | "devDependencies": { 35 | "axios": ">=0.15 <1.0", 36 | "axios-mock-adapter": "^1.7.1", 37 | "babel-cli": "^6.9.0", 38 | "babel-jest": "^18.0.0", 39 | "babel-plugin-transform-async-to-generator": "^6.16.0", 40 | "babel-plugin-transform-object-rest-spread": "^6.16.0", 41 | "babel-preset-es2015": "^6.9.0", 42 | "jest": "^21.0.0", 43 | "prettier": "^1.10.2" 44 | }, 45 | "jest": { 46 | "testRegex": "test.js$" 47 | }, 48 | "dependencies": { 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Errors.js: -------------------------------------------------------------------------------- 1 | class Errors { 2 | /** 3 | * Create a new Errors instance. 4 | */ 5 | constructor(errors = {}) { 6 | this.record(errors); 7 | } 8 | 9 | /** 10 | * Get all the errors. 11 | * 12 | * @return {object} 13 | */ 14 | all() { 15 | return this.errors; 16 | } 17 | 18 | /** 19 | * Determine if any errors exists for the given field or object. 20 | * 21 | * @param {string} field 22 | */ 23 | has(field) { 24 | let hasError = this.errors.hasOwnProperty(field); 25 | 26 | if (!hasError) { 27 | const errors = Object.keys(this.errors).filter( 28 | e => e.startsWith(`${field}.`) || e.startsWith(`${field}[`) 29 | ); 30 | 31 | hasError = errors.length > 0; 32 | } 33 | 34 | return hasError; 35 | } 36 | 37 | first(field) { 38 | return this.get(field)[0]; 39 | } 40 | 41 | get(field) { 42 | return this.errors[field] || []; 43 | } 44 | 45 | /** 46 | * Determine if we have any errors. 47 | * Or return errors for the given keys. 48 | * 49 | * @param {array} keys 50 | */ 51 | any(keys = []) { 52 | if (keys.length === 0) { 53 | return Object.keys(this.errors).length > 0; 54 | } 55 | 56 | let errors = {}; 57 | 58 | keys.forEach(key => errors[key] = this.get(key)); 59 | 60 | return errors; 61 | } 62 | 63 | /** 64 | * Record the new errors. 65 | * 66 | * @param {object} errors 67 | */ 68 | record(errors = {}) { 69 | this.errors = errors; 70 | } 71 | 72 | /** 73 | * Clear a specific field, object or all error fields. 74 | * 75 | * @param {string|null} field 76 | */ 77 | clear(field) { 78 | if (!field) { 79 | this.errors = {}; 80 | 81 | return; 82 | } 83 | 84 | let errors = Object.assign({}, this.errors); 85 | 86 | Object.keys(errors) 87 | .filter(e => e === field || e.startsWith(`${field}.`) || e.startsWith(`${field}[`)) 88 | .forEach(e => delete errors[e]); 89 | 90 | this.errors = errors; 91 | } 92 | } 93 | 94 | export default Errors; 95 | -------------------------------------------------------------------------------- /src/Form.js: -------------------------------------------------------------------------------- 1 | import Errors from './Errors'; 2 | import { guardAgainstReservedFieldName, isArray, isFile, merge, objectToFormData } from './util'; 3 | 4 | class Form { 5 | /** 6 | * Create a new Form instance. 7 | * 8 | * @param {object} data 9 | * @param {object} options 10 | */ 11 | constructor(data = {}, options = {}) { 12 | this.processing = false; 13 | this.successful = false; 14 | 15 | this.withData(data) 16 | .withOptions(options) 17 | .withErrors({}); 18 | } 19 | 20 | withData(data) { 21 | if (isArray(data)) { 22 | data = data.reduce((carry, element) => { 23 | carry[element] = ''; 24 | return carry; 25 | }, {}); 26 | } 27 | 28 | this.setInitialValues(data); 29 | 30 | this.errors = new Errors(); 31 | this.processing = false; 32 | this.successful = false; 33 | 34 | for (const field in data) { 35 | guardAgainstReservedFieldName(field); 36 | 37 | this[field] = data[field]; 38 | } 39 | 40 | return this; 41 | } 42 | 43 | withErrors(errors) { 44 | this.errors = new Errors(errors); 45 | 46 | return this; 47 | } 48 | 49 | withOptions(options) { 50 | this.__options = { 51 | resetOnSuccess: true, 52 | }; 53 | 54 | if (options.hasOwnProperty('resetOnSuccess')) { 55 | this.__options.resetOnSuccess = options.resetOnSuccess; 56 | } 57 | 58 | if (options.hasOwnProperty('onSuccess')) { 59 | this.onSuccess = options.onSuccess; 60 | } 61 | 62 | if (options.hasOwnProperty('onFail')) { 63 | this.onFail = options.onFail; 64 | } 65 | 66 | const windowAxios = typeof window === 'undefined' ? false : window.axios 67 | 68 | this.__http = options.http || windowAxios || require('axios'); 69 | 70 | if (!this.__http) { 71 | throw new Error( 72 | 'No http library provided. Either pass an http option, or install axios.' 73 | ); 74 | } 75 | 76 | return this; 77 | } 78 | 79 | /** 80 | * Fetch all relevant data for the form. 81 | */ 82 | data() { 83 | const data = {}; 84 | 85 | for (const property in this.initial) { 86 | data[property] = this[property]; 87 | } 88 | 89 | return data; 90 | } 91 | 92 | /** 93 | * Fetch specific data for the form. 94 | * 95 | * @param {array} fields 96 | * @return {object} 97 | */ 98 | only(fields) { 99 | return fields.reduce((filtered, field) => { 100 | filtered[field] = this[field]; 101 | return filtered; 102 | }, {}); 103 | } 104 | 105 | /** 106 | * Reset the form fields. 107 | */ 108 | reset() { 109 | merge(this, this.initial); 110 | 111 | this.errors.clear(); 112 | } 113 | 114 | setInitialValues(values) { 115 | this.initial = {}; 116 | 117 | merge(this.initial, values); 118 | } 119 | 120 | populate(data) { 121 | Object.keys(data).forEach(field => { 122 | guardAgainstReservedFieldName(field); 123 | 124 | if (this.hasOwnProperty(field)) { 125 | merge(this, { [field]: data[field] }); 126 | } 127 | }); 128 | 129 | return this; 130 | } 131 | 132 | /** 133 | * Clear the form fields. 134 | */ 135 | clear() { 136 | for (const field in this.initial) { 137 | this[field] = ''; 138 | } 139 | 140 | this.errors.clear(); 141 | } 142 | 143 | /** 144 | * Send a POST request to the given URL. 145 | * 146 | * @param {string} url 147 | */ 148 | post(url) { 149 | return this.submit('post', url); 150 | } 151 | 152 | /** 153 | * Send a PUT request to the given URL. 154 | * 155 | * @param {string} url 156 | */ 157 | put(url) { 158 | return this.submit('put', url); 159 | } 160 | 161 | /** 162 | * Send a PATCH request to the given URL. 163 | * 164 | * @param {string} url 165 | */ 166 | patch(url) { 167 | return this.submit('patch', url); 168 | } 169 | 170 | /** 171 | * Send a DELETE request to the given URL. 172 | * 173 | * @param {string} url 174 | */ 175 | delete(url) { 176 | return this.submit('delete', url); 177 | } 178 | 179 | /** 180 | * Submit the form. 181 | * 182 | * @param {string} requestType 183 | * @param {string} url 184 | */ 185 | submit(requestType, url) { 186 | this.__validateRequestType(requestType); 187 | this.errors.clear(); 188 | this.processing = true; 189 | this.successful = false; 190 | 191 | return new Promise((resolve, reject) => { 192 | this.__http[requestType]( 193 | url, 194 | this.hasFiles() ? objectToFormData(this.data()) : this.data() 195 | ) 196 | .then(response => { 197 | this.processing = false; 198 | this.onSuccess(response.data); 199 | 200 | resolve(response.data); 201 | }) 202 | .catch(error => { 203 | this.processing = false; 204 | this.onFail(error); 205 | 206 | reject(error); 207 | }); 208 | }); 209 | } 210 | 211 | /** 212 | * @returns {boolean} 213 | */ 214 | hasFiles() { 215 | for (const property in this.initial) { 216 | if (this.hasFilesDeep(this[property])) { 217 | return true; 218 | } 219 | } 220 | 221 | return false; 222 | }; 223 | 224 | /** 225 | * @param {Object|Array} object 226 | * @returns {boolean} 227 | */ 228 | hasFilesDeep(object) { 229 | if (object === null) { 230 | return false; 231 | } 232 | 233 | if (typeof object === 'object') { 234 | for (const key in object) { 235 | if (object.hasOwnProperty(key)) { 236 | if (this.hasFilesDeep(object[key])) { 237 | return true; 238 | } 239 | } 240 | } 241 | } 242 | 243 | if (Array.isArray(object)) { 244 | for (const key in object) { 245 | if (object.hasOwnProperty(key)) { 246 | return this.hasFilesDeep(object[key]); 247 | } 248 | } 249 | } 250 | 251 | return isFile(object); 252 | } 253 | 254 | /** 255 | * Handle a successful form submission. 256 | * 257 | * @param {object} data 258 | */ 259 | onSuccess(data) { 260 | this.successful = true; 261 | 262 | if (this.__options.resetOnSuccess) { 263 | this.reset(); 264 | } 265 | } 266 | 267 | /** 268 | * Handle a failed form submission. 269 | * 270 | * @param {object} data 271 | */ 272 | onFail(error) { 273 | this.successful = false; 274 | 275 | if (error.response && error.response.data.errors) { 276 | this.errors.record(error.response.data.errors); 277 | } 278 | } 279 | 280 | /** 281 | * Get the error message(s) for the given field. 282 | * 283 | * @param field 284 | */ 285 | hasError(field) { 286 | return this.errors.has(field); 287 | } 288 | 289 | /** 290 | * Get the first error message for the given field. 291 | * 292 | * @param {string} field 293 | * @return {string} 294 | */ 295 | getError(field) { 296 | return this.errors.first(field); 297 | } 298 | 299 | /** 300 | * Get the error messages for the given field. 301 | * 302 | * @param {string} field 303 | * @return {array} 304 | */ 305 | getErrors(field) { 306 | return this.errors.get(field); 307 | } 308 | 309 | __validateRequestType(requestType) { 310 | const requestTypes = ['get', 'delete', 'head', 'post', 'put', 'patch']; 311 | 312 | if (requestTypes.indexOf(requestType) === -1) { 313 | throw new Error( 314 | `\`${requestType}\` is not a valid request type, ` + 315 | `must be one of: \`${requestTypes.join('`, `')}\`.` 316 | ); 317 | } 318 | } 319 | 320 | static create(data = {}) { 321 | return new Form().withData(data); 322 | } 323 | } 324 | 325 | export default Form; 326 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Form'; 2 | export { default as Form } from './Form'; 3 | export { default as Errors } from './Errors'; 4 | -------------------------------------------------------------------------------- /src/util/fieldNameValidation.js: -------------------------------------------------------------------------------- 1 | export const reservedFieldNames = [ 2 | '__http', 3 | '__options', 4 | '__validateRequestType', 5 | 'clear', 6 | 'data', 7 | 'delete', 8 | 'errors', 9 | 'getError', 10 | 'getErrors', 11 | 'hasError', 12 | 'initial', 13 | 'onFail', 14 | 'only', 15 | 'onSuccess', 16 | 'patch', 17 | 'populate', 18 | 'post', 19 | 'processing', 20 | 'successful', 21 | 'put', 22 | 'reset', 23 | 'submit', 24 | 'withData', 25 | 'withErrors', 26 | 'withOptions', 27 | ]; 28 | 29 | export function guardAgainstReservedFieldName(fieldName) { 30 | if (reservedFieldNames.indexOf(fieldName) !== -1) { 31 | throw new Error( 32 | `Field name ${fieldName} isn't allowed to be used in a Form or Errors instance.` 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/util/formData.js: -------------------------------------------------------------------------------- 1 | export function objectToFormData(object, formData = new FormData(), parent = null) { 2 | if (object === null || object === 'undefined' || object.length === 0) { 3 | return formData.append(parent, object); 4 | } 5 | 6 | for (const property in object) { 7 | if (object.hasOwnProperty(property)) { 8 | appendToFormData(formData, getKey(parent, property), object[property]); 9 | } 10 | } 11 | 12 | return formData; 13 | } 14 | 15 | function getKey(parent, property) { 16 | return parent ? parent + '[' + property + ']' : property; 17 | } 18 | 19 | function appendToFormData(formData, key, value) { 20 | if (value instanceof Date) { 21 | return formData.append(key, value.toISOString()); 22 | } 23 | 24 | if (value instanceof File) { 25 | return formData.append(key, value, value.name); 26 | } 27 | 28 | if (typeof value === "boolean") { 29 | return formData.append(key, value ? '1' : '0'); 30 | } 31 | 32 | if (value === null) { 33 | return formData.append(key, ''); 34 | } 35 | 36 | if (typeof value !== 'object') { 37 | return formData.append(key, value); 38 | } 39 | 40 | objectToFormData(value, formData, key); 41 | } 42 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | export * from './objects'; 2 | export * from './formData'; 3 | export * from './fieldNameValidation'; 4 | -------------------------------------------------------------------------------- /src/util/objects.js: -------------------------------------------------------------------------------- 1 | export function isArray(object) { 2 | return Object.prototype.toString.call(object) === '[object Array]'; 3 | } 4 | 5 | export function isFile(object) { 6 | return object instanceof File || object instanceof FileList; 7 | } 8 | 9 | export function merge(a, b) { 10 | for (const key in b) { 11 | a[key] = cloneDeep(b[key]); 12 | } 13 | } 14 | 15 | export function cloneDeep(object) { 16 | if (object === null) { 17 | return null; 18 | } 19 | 20 | if (isFile(object)) { 21 | return object; 22 | } 23 | 24 | if (Array.isArray(object)) { 25 | const clone = []; 26 | 27 | for (const key in object) { 28 | if (object.hasOwnProperty(key)) { 29 | clone[key] = cloneDeep(object[key]); 30 | } 31 | } 32 | 33 | return clone; 34 | } 35 | 36 | if (typeof object === 'object') { 37 | const clone = {}; 38 | 39 | for (const key in object) { 40 | if (object.hasOwnProperty(key)) { 41 | clone[key] = cloneDeep(object[key]); 42 | } 43 | } 44 | 45 | return clone; 46 | } 47 | 48 | return object; 49 | } 50 | --------------------------------------------------------------------------------