diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..561a6af Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 3ff291d..7c0c7de 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,152 @@ # vue-laravel-form Vue Js Forms with laravel validation -![GitHub manifest version](https://img.shields.io/github/manifest-json/v/riteshsingh1/vue-laravel-form.svg) +![npm](https://img.shields.io/npm/v/@imritesh/form.svg) + + +This package provides a Form class that can validation our forms with laravel backend validation logic. The class is meant to be used with a Laravel back end. + + +## Install + +You can install the package via yarn (or npm): +```bash +$ npm install @imritesh/form +``` +```bash +$ yarn add @imritesh/form +``` +By default, this package expects `axios` to be installed + +```bash +$ yarn add axios +``` + +## Usage +```html +
+ + + +
+``` +```js + +import Form from 'form-backend-validation'; + +// Instantiate a form class with some values +const form = new Form({ + field1: 'value 1', + field2: 'value 2', + person: { + first_name: 'John', + last_name: 'Doe', + }, +}); + +// A form can also be initiated with an array +const form = new Form(['field1', 'field2']); + +// Submit the form, you can also use `.put`, `.patch` and `.delete` +form.post(anUrl) + .then(response => ...) + .catch(response => ...); + +// Returns true if request is being executed +form.processing; + +// If there were any validation errors, you easily access them + +// Example error response (json) +{ + "errors": { + "field1": ['Value is required'], + "field2": ['Value is required'] + } +} + +// Returns an object in which the keys are the field names +// and the values array with error message sent by the server +form.errors.all(); + +// Returns true if there were any error +form.errors.any(); + +// Returns true if there is an error for the given field name or object +form.errors.has(key); + +// Returns the first error for the given field name +form.errors.first(key); + +// Returns an array with errors for the given field name +form.errors.get(key); + +// Shortcut for getting the errors for the given field name +form.getError(key); + +// Clear all errors +form.errors.clear(); + +// Clear the error of the given field name or all errors on the given object +form.errors.clear(key); + +// Returns an object containing fields based on the given array of field names +form.only(keys); + +// Reset the values of the form to those passed to the constructor +form.reset(); + +// Set the values which should be used when calling reset() +form.setInitialValues(); + +// Populate a form after its instantiation, the populated fields won't override the initial fields +// Fields not present at instantiation will not be populated +const form = new Form({ + field1: '', + field2: '', +}); + +form.populate({ + field1: 'foo', + field2: 'bar', +}); + +``` + +### Options + +The `Form` class accepts a second `options` parameter. + +```js +const form = new Form({ + field1: 'value 1', + field2: 'value 2', +}, { + resetOnSuccess: false, +}); +``` + +You can also pass options via a `withOptions` method (this example uses the `create` factory method. + +``` +const form = Form.create() + .withOptions({ resetOnSuccess: false }) + .withData({ + field1: 'value 1', + field2: 'value 2', + }); +``` + +#### `resetOnSuccess: bool` + +Default: `true`. Set to `false` if you don't want the form to reset to its original values after a succesful submit. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1e02007 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@imritesh/form", + "version": "2.0.0", + "description": "An easy way to validate forms using laravel backend logic", + "author": "Ritesh Singh", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/riteshsingh1/vue-laravel-form.git" + }, + "main": "src/index.js", + "keywords": [ + "vuejs-form", + "vue-form", + "laravel-form", + "validation", + "server" + ] +} diff --git a/src/Errors.js b/src/Errors.js new file mode 100755 index 0000000..f0a9f5b --- /dev/null +++ b/src/Errors.js @@ -0,0 +1,83 @@ +class Errors { + /** + * Create a new Errors instance. + */ + constructor(errors = {}) { + this.record(errors); + } + + /** + * Get all the errors. + * + * @return {object} + */ + all() { + return this.errors; + } + + /** + * Determine if any errors exists for the given field or object. + * + * @param {string} field + */ + has(field) { + let hasError = this.errors.hasOwnProperty(field); + + if (!hasError) { + const errors = Object.keys(this.errors).filter( + e => e.startsWith(`${field}.`) || e.startsWith(`${field}[`) + ); + + hasError = errors.length > 0; + } + + return hasError; + } + + first(field) { + return this.get(field)[0]; + } + + get(field) { + return this.errors[field] || []; + } + + /** + * Determine if we have any errors. + */ + any() { + return Object.keys(this.errors).length > 0; + } + + /** + * Record the new errors. + * + * @param {object} errors + */ + record(errors = {}) { + this.errors = errors; + } + + /** + * Clear a specific field, object or all error fields. + * + * @param {string|null} field + */ + clear(field) { + if (!field) { + this.errors = {}; + + return; + } + + let errors = Object.assign({}, this.errors); + + Object.keys(errors) + .filter(e => e === field || e.startsWith(`${field}.`) || e.startsWith(`${field}[`)) + .forEach(e => delete errors[e]); + + this.errors = errors; + } +} + +export default Errors; diff --git a/src/Form.js b/src/Form.js new file mode 100755 index 0000000..ab104ae --- /dev/null +++ b/src/Form.js @@ -0,0 +1,325 @@ +import Errors from './Errors'; +import { guardAgainstReservedFieldName, isArray, isFile, merge, objectToFormData } from './util'; + +class Form { + /** + * Create a new Form instance. + * + * @param {object} data + * @param {object} options + */ + constructor(data = {}, options = {}) { + this.processing = false; + this.successful = false; + + this.withData(data) + .withOptions(options) + .withErrors({}); + } + + withData(data) { + if (isArray(data)) { + data = data.reduce((carry, element) => { + carry[element] = ''; + return carry; + }, {}); + } + + this.setInitialValues(data); + + this.errors = new Errors(); + this.processing = false; + this.successful = false; + + for (const field in data) { + guardAgainstReservedFieldName(field); + + this[field] = data[field]; + } + + return this; + } + + withErrors(errors) { + this.errors = new Errors(errors); + + return this; + } + + withOptions(options) { + this.__options = { + resetOnSuccess: true, + }; + + if (options.hasOwnProperty('resetOnSuccess')) { + this.__options.resetOnSuccess = options.resetOnSuccess; + } + + if (options.hasOwnProperty('onSuccess')) { + this.onSuccess = options.onSuccess; + } + + if (options.hasOwnProperty('onFail')) { + this.onFail = options.onFail; + } + + const windowAxios = typeof window === 'undefined' ? false : window.axios + + this.__http = options.http || windowAxios || require('axios'); + + if (!this.__http) { + throw new Error( + 'No http library provided. Either pass an http option, or install axios.' + ); + } + + return this; + } + + /** + * Fetch all relevant data for the form. + */ + data() { + const data = {}; + + for (const property in this.initial) { + data[property] = this[property]; + } + + return data; + } + + /** + * Fetch specific data for the form. + * + * @param {array} fields + * @return {object} + */ + only(fields) { + return fields.reduce((filtered, field) => { + filtered[field] = this[field]; + return filtered; + }, {}); + } + + /** + * Reset the form fields. + */ + reset() { + merge(this, this.initial); + + this.errors.clear(); + } + + setInitialValues(values) { + this.initial = {}; + + merge(this.initial, values); + } + + populate(data) { + Object.keys(data).forEach(field => { + guardAgainstReservedFieldName(field); + + if (this.hasOwnProperty(field)) { + merge(this, { [field]: data[field] }); + } + }); + + return this; + } + + /** + * Clear the form fields. + */ + clear() { + for (const field in this.initial) { + this[field] = ''; + } + + this.errors.clear(); + } + + /** + * Send a POST request to the given URL. + * + * @param {string} url + */ + post(url) { + return this.submit('post', url); + } + + /** + * Send a PUT request to the given URL. + * + * @param {string} url + */ + put(url) { + return this.submit('put', url); + } + + /** + * Send a PATCH request to the given URL. + * + * @param {string} url + */ + patch(url) { + return this.submit('patch', url); + } + + /** + * Send a DELETE request to the given URL. + * + * @param {string} url + */ + delete(url) { + return this.submit('delete', url); + } + + /** + * Submit the form. + * + * @param {string} requestType + * @param {string} url + */ + submit(requestType, url) { + this.__validateRequestType(requestType); + this.errors.clear(); + this.processing = true; + this.successful = false; + + return new Promise((resolve, reject) => { + this.__http[requestType]( + url, + this.hasFiles() ? objectToFormData(this.data()) : this.data() + ) + .then(response => { + this.processing = false; + this.onSuccess(response.data); + + resolve(response.data); + }) + .catch(error => { + this.processing = false; + this.onFail(error); + + reject(error); + }); + }); + } + + /** + * @returns {boolean} + */ + hasFiles() { + for (const property in this.initial) { + if (this.hasFilesDeep(this[property])) { + return true; + } + } + + return false; + }; + + /** + * @param {Object|Array} object + * @returns {boolean} + */ + hasFilesDeep(object) { + if (object === null) { + return false; + } + + if (typeof object === 'object') { + for (const key in object) { + if (object.hasOwnProperty(key)) { + if (isFile(object[key])) { + return true; + } + } + } + } + + if (Array.isArray(object)) { + for (const key in object) { + if (object.hasOwnProperty(key)) { + return this.hasFilesDeep(object[key]); + } + } + } + + return isFile(object); + } + + /** + * Handle a successful form submission. + * + * @param {object} data + */ + onSuccess(data) { + this.successful = true; + + if (this.__options.resetOnSuccess) { + this.reset(); + } + } + + /** + * Handle a failed form submission. + * + * @param {object} data + */ + onFail(error) { + this.successful = false; + + if (error.response && error.response.data.errors) { + this.errors.record(error.response.data.errors); + } + } + + /** + * Get the error message(s) for the given field. + * + * @param field + */ + hasError(field) { + return this.errors.has(field); + } + + /** + * Get the first error message for the given field. + * + * @param {string} field + * @return {string} + */ + getError(field) { + return this.errors.first(field); + } + + /** + * Get the error messages for the given field. + * + * @param {string} field + * @return {array} + */ + getErrors(field) { + return this.errors.get(field); + } + + __validateRequestType(requestType) { + const requestTypes = ['get', 'delete', 'head', 'post', 'put', 'patch']; + + if (requestTypes.indexOf(requestType) === -1) { + throw new Error( + `\`${requestType}\` is not a valid request type, ` + + `must be one of: \`${requestTypes.join('`, `')}\`.` + ); + } + } + + static create(data = {}) { + return new Form().withData(data); + } +} + +export default Form; diff --git a/src/index.js b/src/index.js new file mode 100755 index 0000000..804de2d --- /dev/null +++ b/src/index.js @@ -0,0 +1,3 @@ +export { default } from './Form'; +export { default as Form } from './Form'; +export { default as Errors } from './Errors'; diff --git a/src/util/fieldNameValidation.js b/src/util/fieldNameValidation.js new file mode 100755 index 0000000..9c5e26b --- /dev/null +++ b/src/util/fieldNameValidation.js @@ -0,0 +1,35 @@ +export const reservedFieldNames = [ + '__http', + '__options', + '__validateRequestType', + 'clear', + 'data', + 'delete', + 'errors', + 'getError', + 'getErrors', + 'hasError', + 'initial', + 'onFail', + 'only', + 'onSuccess', + 'patch', + 'populate', + 'post', + 'processing', + 'successful', + 'put', + 'reset', + 'submit', + 'withData', + 'withErrors', + 'withOptions', +]; + +export function guardAgainstReservedFieldName(fieldName) { + if (reservedFieldNames.indexOf(fieldName) !== -1) { + throw new Error( + `Field name ${fieldName} isn't allowed to be used in a Form or Errors instance.` + ); + } +} diff --git a/src/util/formData.js b/src/util/formData.js new file mode 100755 index 0000000..a253b75 --- /dev/null +++ b/src/util/formData.js @@ -0,0 +1,33 @@ +export function objectToFormData(object, formData = new FormData(), parent = null) { + if (object === null || object === 'undefined' || object.length === 0) { + return formData.append(parent, object); + } + + for (const property in object) { + if (object.hasOwnProperty(property)) { + appendToFormData(formData, getKey(parent, property), object[property]); + } + } + + return formData; +} + +function getKey(parent, property) { + return parent ? parent + '[' + property + ']' : property; +} + +function appendToFormData(formData, key, value) { + if (value instanceof Date) { + return formData.append(key, value.toISOString()); + } + + if (value instanceof File) { + return formData.append(key, value, value.name); + } + + if (typeof value !== 'object') { + return formData.append(key, value); + } + + objectToFormData(value, formData, key); +} diff --git a/src/util/index.js b/src/util/index.js new file mode 100755 index 0000000..2288e88 --- /dev/null +++ b/src/util/index.js @@ -0,0 +1,3 @@ +export * from './objects'; +export * from './formData'; +export * from './fieldNameValidation'; diff --git a/src/util/objects.js b/src/util/objects.js new file mode 100755 index 0000000..1783d80 --- /dev/null +++ b/src/util/objects.js @@ -0,0 +1,49 @@ +export function isArray(object) { + return Object.prototype.toString.call(object) === '[object Array]'; +} + +export function isFile(object) { + return object instanceof File || object instanceof FileList; +} + +export function merge(a, b) { + for (const key in b) { + a[key] = cloneDeep(b[key]); + } +} + +export function cloneDeep(object) { + if (object === null) { + return null; + } + + if (isFile(object)) { + return object; + } + + if (Array.isArray(object)) { + const clone = []; + + for (const key in object) { + if (object.hasOwnProperty(key)) { + clone[key] = cloneDeep(object[key]); + } + } + + return clone; + } + + if (typeof object === 'object') { + const clone = {}; + + for (const key in object) { + if (object.hasOwnProperty(key)) { + clone[key] = cloneDeep(object[key]); + } + } + + return clone; + } + + return object; +}