diff --git a/.vscode/launch.json b/.vscode/launch.json index 86eff0d..4b6d5ac 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,8 @@ "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "skipFiles": ["/**"] + "skipFiles": ["/**"], + "sourceMaps": true } ] } diff --git a/CHANGELOG.MD b/CHANGELOG.MD index b932dc4..945a09e 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,11 @@ +# 2.0.0 + +- don't throw if context not provided in validator + +- remove debounce option + +- dont support async validations + # 1.2.0 - add onValidated - fired once after validation occurs. diff --git a/examples/index.js b/examples/example.js similarity index 75% rename from examples/index.js rename to examples/example.js index 43ea56c..892f444 100644 --- a/examples/index.js +++ b/examples/example.js @@ -1,87 +1,15 @@ import { render } from 'react-dom' -import React, { useState, useCallback } from 'react' +import React from 'react' import { Form, Validator } from '@tswaters/react-form-validation' -const AsyncValidation = () => { - const [shouldResolve, setShouldResolve] = useState(false) - const [loading, setLoading] = useState(false) - const getUserService = useCallback( - () => - new Promise((resolve, reject) => - setTimeout( - () => (shouldResolve ? resolve() : reject(new Error('user exists'))), - 500 - ) - ), - [shouldResolve] - ) - - const validation = useCallback(async () => { - setLoading(true) - try { - await getUserService() - } finally { - setLoading(false) - } - }, [getUserService]) - - return ( - <> - - {({ error, validated }) => ( -
- - - {error &&
{error.message}
} -
- )} -
-
-
- setShouldResolve(true)} - checked={shouldResolve} - /> - -
-
- setShouldResolve(false)} - checked={!shouldResolve} - /> - -
-
- - ) -} - render( -
console.log('SUBMITTING!!!!!')} noValidate> + { + e.preventDefault() + console.log('SUBMITTING!!!!!') + }} + noValidate + >
@@ -121,7 +49,7 @@ render( validation={(input, fields) => { const other = fields.find((x) => x.id === 'name') if (!other || other.value !== input.value) - throw new Error('must match') + return new Error('must match') }} > {({ error, validated }) => { @@ -148,12 +76,6 @@ render(
-
-
Async Validation
-
- -
-
diff --git a/examples/index.html b/examples/index.html index 6bd426f..c2eb7be 100644 --- a/examples/index.html +++ b/examples/index.html @@ -23,4 +23,8 @@ }
- + diff --git a/package.json b/package.json index e81d5cd..6198ca0 100644 --- a/package.json +++ b/package.json @@ -59,16 +59,10 @@ "sinon": "^9.0.2" }, "babel": { + "sourceMaps": "both", + "retainLines": true, "presets": [ - [ - "@babel/preset-env", - { - "exclude": [ - "@babel/plugin-transform-regenerator", - "@babel/plugin-transform-async-to-generator" - ] - } - ], + "@babel/preset-env", "@babel/preset-react" ], "plugins": [ @@ -153,7 +147,8 @@ "spec": [ "test/setup.js", "test/**/*.test.js" - ] + ], + "timeout": false }, "bugs": { "url": "https://github.com/tswaters/react-form-validation/issues" diff --git a/readme.md b/readme.md index 5bd609f..15edaed 100644 --- a/readme.md +++ b/readme.md @@ -57,8 +57,6 @@ const example = () => { - **NOTE:** onSubmit will only be called if the form passes validation! -- **NOTE:** this library calls `preventDefault` to do async validations. - ### Form element components Input/Select/TextArea take all the same props. @@ -176,74 +174,44 @@ const LoginForm = () => { You can provide validations to the `` element and they will be called as part of validating the element. -These validation routines can be async and await their response. Form submission will be blocked while validations are awaiting their response. - -The validation routine will consider something as failed with the following returns: +Validation routines must be synchronous. Validation will be considered failed with the following returns: - a string - the error returned will be `new Error(returnValue)` - an error - the error returned will be `returnValue` -- throw an error - the error returned will be `thrownValue` - Otherwise, the validation is considered to have succeeded. ```js -import { Validator, Input } from '@tswaters/react-form-validation' +import { Form, Validator } from '@tswaters/react-form-validation' const MyForm = () => { const [error, setError] = useState(null) const [response, setResponse] = useState(null) - const [loading, setLoading] = useState(true) - - const validation = useMemo( - () => [ - async (input) => { - setLoading(true) - try { - await UserService.checkIfAlreadyExists(input.value) - } finally { - setLoading(false) - } - }, - ], - [] - ) - const handleSubmit = useCallback(async (e) => { + const validation = useCallback((inputRef) => { + if (restrictedWords.includes(inputRef.value)) + return new Error('cant use restricted words') + }, []) + + const handleSubmit = useCallback((e) => { e.preventDefault() - try { - const res = await fetch('/api', { - method: 'POST', - body: new FormData(e.target), - headers: { - Accept: 'application/json', - }, - }) - const data = await res.json() - if (res.ok) return setResponse(data) - throw data - } catch (err) { - setError(err) - } + console.log('completely valid, no restricted words here!') }, []) return ( - - {({ error }) => ( - - )} + + {({ error }) => { + return ( + <> + + {error &&
{error.message}
} + + ) + }}
- {response &&
{response}
} - {error &&
{error.message}
} ) } diff --git a/rollup.config.js b/rollup.config.js index eff09e9..352eef2 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -25,7 +25,7 @@ const config = (format, file, minify, server = false) => ({ port: 8001, host: '0.0.0.0', path: 'examples/index.html', - contentBase: ['examples', 'dist', 'node_modules'], + contentBase: ['examples', 'dist'], open: false, wait: 500, }), diff --git a/src/context.js b/src/context.js deleted file mode 100644 index e23b8e3..0000000 --- a/src/context.js +++ /dev/null @@ -1,2 +0,0 @@ -import { createContext } from 'react' -export const FormContext = createContext(null) diff --git a/src/form.js b/src/form.js index 4f9ce6e..0ebf2a4 100644 --- a/src/form.js +++ b/src/form.js @@ -1,36 +1,33 @@ -import React, { memo, useCallback, useRef, forwardRef } from 'react' +import React, { + memo, + createContext, + useCallback, + useRef, + forwardRef, + useMemo, +} from 'react' import { func } from 'prop-types' -import { FormContext } from './context' +export const FormContext = createContext(null) -const getElement = (search, elements, mapper = (x) => x) => - elements[ - Array.from(elements) - .map(mapper) - .findIndex((x) => x === search || x.id === search || x.name === search) - ] +const getName = (ref) => ref.id || ref.name const Form = forwardRef(({ onSubmit, ...rest }, ref) => { const formRef = useRef(ref) const touched = useRef({}) - const fields = useRef([]) + const fields = useRef({}) /** * This is invoked from `useValidation` * Each element, as it's mounted, must register with us so we can do things with them * This happens in a `useEffect` - the disposable will call the unregister function. */ - const register = useCallback( - (field, details) => fields.current.push({ field, details }), - [] - ) + const register = useCallback((ref, ctx) => { + fields.current[getName(ref)] = { ref, ctx } + }, []) - const unregister = useCallback( - (field) => - (fields.current = fields.current.filter( - (f) => f.field.name !== field.name - )), - [] - ) + const unregister = useCallback((ref) => { + delete fields.current[getName(ref)] + }, []) /** * Validates a single input. @@ -48,33 +45,27 @@ const Form = forwardRef(({ onSubmit, ...rest }, ref) => { * @param {HtmlInputElement} formInput the input to validate * @param {boolean} [force=false] whether to bypass touched check. */ - const validateSingle = useCallback(async (formInput, force = false) => { - const isTouched = touched.current[formInput.name] + const validateSingle = useCallback((ref, force = false) => { + const isTouched = touched.current[ref.name] if (!force && !isTouched) return - formInput.setCustomValidity('') - if (!formInput.checkValidity()) return + ref.setCustomValidity('') + if (!ref.checkValidity()) return // the invalid event will have fired. - let error = null - const field = getElement(formInput, fields.current, (x) => x.field) - const others = fields.current.map((x) => x.field) + const { ctx } = fields.current[getName(ref)] + const refs = Object.entries(fields.current).map(([, { ref }]) => ref) - for (const fn of field.details.validation ?? []) { - try { - let err = await fn(formInput, others) - if (typeof err === 'string') error = new Error(err) - else if (err instanceof Error) error = err - } catch (err) { - error = err - } - if (error) break - } + let [error] = (ctx.validation ?? []) + .map((fn) => fn(ref, refs)) + .filter((valResult) => valResult != null) - if (error) { - formInput.setCustomValidity(error.message) - formInput.checkValidity() + if (typeof error === 'string') error = new Error(error) + + if (error != null) { + ref.setCustomValidity(error.message) + ref.checkValidity() } else { - field.details.updateState(error, formInput.validity) + ctx.updateState(null, ref.validity) } }, []) @@ -83,31 +74,37 @@ const Form = forwardRef(({ onSubmit, ...rest }, ref) => { * If input has `others`: upon validation, all elements in `other` are validated as well. */ const validate = useCallback( - async ({ target: element }) => { - const field = getElement(element, fields.current, (x) => x.field) - await validateSingle(element) - for (const item of field.details.otherArray) { - const other = getElement(item, element.form.elements) - if (other) await validateSingle(other) - } + ({ target: element }) => { + const { ctx } = fields.current[getName(element)] + const allFields = ctx.otherArray.reduce( + (acc, item) => { + const other = fields.current[item] + if (other) acc.push(other.ref) + return acc + }, + [element] + ) + + allFields.forEach((field) => validateSingle(field)) }, [validateSingle] ) /** * Form submit handler - * Verify each of our inputs passes validation before calling onSubmit - * But if validation fails, it won't ever be called - make sure to not submit the form. - * Calling `.persist()` because when async comes back, the `e.target` is null. + * Verify each of our inputs passes custom validation before calling onSubmit + * If custom validation fails replicate existing dom behavior of not submitting */ const handleSubmit = useCallback( - async (e) => { - e.persist() - e.preventDefault() - for (const { field } of fields.current) { - await validateSingle(field, true) + (e) => { + for (const [, { ref }] of Object.entries(fields.current)) { + validateSingle(ref, true) + } + if (e.target.checkValidity()) { + onSubmit?.(e) + } else { + e.preventDefault() } - if (e.target.checkValidity()) onSubmit?.(e) }, [onSubmit, validateSingle] ) @@ -117,15 +114,18 @@ const Form = forwardRef(({ onSubmit, ...rest }, ref) => { [touched] ) + const contextValue = useMemo( + () => ({ + register, + unregister, + validate, + setInputTouched, + }), + [register, unregister, validate, setInputTouched] + ) + return ( - +
) diff --git a/src/index.js b/src/index.js index 8bd8128..df6cd63 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,3 @@ -export { FormContext } from './context' export { Validator } from './validator' - -export { Form } from './form' +export { Form, FormContext } from './form' export { Input, Select, TextArea } from './input' diff --git a/src/input.js b/src/input.js index 6600064..eebf7c9 100644 --- a/src/input.js +++ b/src/input.js @@ -1,5 +1,5 @@ import React, { memo, useRef, forwardRef } from 'react' -import { func, bool, arrayOf, oneOfType, string, number } from 'prop-types' +import { func, bool, arrayOf, oneOfType, string } from 'prop-types' import { useValidation } from './use-validation' @@ -10,7 +10,6 @@ const propTypes = { onFocus: func, validation: oneOfType([arrayOf(func), func]), other: oneOfType([arrayOf(string), string]), - debounce: number, recheck: bool, blur: bool, change: bool, @@ -31,7 +30,6 @@ const createInput = (inputType) => { onChange, onFocus, validation, - debounce, other, recheck, blur, @@ -58,7 +56,6 @@ const createInput = (inputType) => { onClick, onFocus, validation, - debounce, other, recheck, blur, @@ -81,7 +78,6 @@ const createInput = (inputType) => { } ) - Wrapped.isFormElement = true Wrapped.displayName = `Validated(${inputType})` Wrapped.propTypes = propTypes diff --git a/src/use-validation.js b/src/use-validation.js index 35cffd5..9df9a6f 100644 --- a/src/use-validation.js +++ b/src/use-validation.js @@ -1,13 +1,6 @@ -import { - useContext, - useEffect, - useCallback, - useState, - useRef, - useMemo, -} from 'react' +import { useContext, useEffect, useCallback, useState, useMemo } from 'react' -import { FormContext } from './context' +import { FormContext } from './form' const errors = new Map() const getErrorKey = (err, code) => `${code}_${err.message}` @@ -29,7 +22,6 @@ const useValidation = ( onClick, onFocus, validation, - debounce, other, recheck, blur, @@ -41,29 +33,15 @@ const useValidation = ( onValidated, } ) => { - const ctx = useContext(FormContext) - if (ctx == null) throw new Error('Input requires Form context') - - const { register, unregister, validate, setInputTouched } = ctx + const { register, unregister, validate, setInputTouched } = useContext( + FormContext + ) - const timeoutRef = useRef(null) - const argsRef = useRef(null) const [validated, setValidated] = useState(false) const [valid, setValid] = useState(null) const [error, setError] = useState(null) const [invalid, setInvalid] = useState(null) - const waitForValidation = useCallback( - (e) => { - if (debounce == null) return validate(e) - e.persist() - clearTimeout(timeoutRef.current) - argsRef.current = e - timeoutRef.current = setTimeout(() => validate(argsRef.current), debounce) - }, - [debounce, validate] - ) - const updateState = useCallback( (error, validity) => { const is_valid = error == null || error === false || error === '' @@ -76,27 +54,21 @@ const useValidation = ( [setValid, setInvalid, setError, setValidated] ) - useEffect(() => { - onValid?.(valid) - }, [onValid, valid]) - useEffect(() => { onError?.(error) - }, [onError, error]) - - useEffect(() => { onInvalid?.(invalid) - }, [onInvalid, invalid]) - - useEffect(() => { + onValid?.(valid) onValidated?.(validated) - }, [onValidated, validated]) - - const handleOnInvalid = useCallback( - ({ target: element }) => - updateState(new Error(element.validationMessage), element.validity), - [updateState] - ) + }, [ + onError, + onInvalid, + onValid, + onValidated, + error, + invalid, + valid, + validated, + ]) const handleFocus = useCallback( (e) => { @@ -109,28 +81,28 @@ const useValidation = ( const handleChange = useCallback( (e) => { onChange?.(e) - if ((validated && recheck) || change) waitForValidation(e) + if ((validated && recheck) || change) validate(e) }, - [onChange, recheck, change, validated, waitForValidation] + [onChange, recheck, change, validated, validate] ) const handleBlur = useCallback( (e) => { onBlur?.(e) - if (blur) waitForValidation(e) + if (blur) validate(e) }, - [onBlur, blur, waitForValidation] + [onBlur, blur, validate] ) const handleClick = useCallback( (e) => { onClick?.(e) - if (click) waitForValidation(e) + if (click) validate(e) }, - [onClick, click, waitForValidation] + [onClick, click, validate] ) - const details = useMemo( + const ctx = useMemo( () => ({ validation: validation == null @@ -146,17 +118,23 @@ const useValidation = ( useEffect(() => { const thisRef = innerRef.current - register(thisRef, details) + register(thisRef, ctx) return () => unregister(thisRef) - }, [innerRef, register, unregister, details]) + }, [innerRef, register, unregister, ctx]) useEffect(() => { const thisRef = innerRef.current - thisRef.addEventListener('invalid', handleOnInvalid) - return () => thisRef.removeEventListener('invalid', handleOnInvalid) - }, [innerRef, handleOnInvalid]) - - return { handleBlur, handleChange, handleClick, handleFocus } + const handler = ({ target: { validationMessage, validity } }) => { + updateState(new Error(validationMessage), validity) + } + thisRef.addEventListener('invalid', handler) + return () => thisRef.removeEventListener('invalid', handler) + }, [innerRef, updateState]) + + return useMemo( + () => ({ handleBlur, handleChange, handleClick, handleFocus }), + [handleBlur, handleChange, handleClick, handleFocus] + ) } export { useValidation } diff --git a/test/input.test.js b/test/input.test.js index 5417344..761d65d 100644 --- a/test/input.test.js +++ b/test/input.test.js @@ -1,17 +1,13 @@ import React from 'react' -import { equal, throws } from 'assert' +import { equal } from 'assert' import { stub } from 'sinon' import { mount } from 'enzyme' import { Form, Input, Select, TextArea } from '../src/index' -import { wait } from './utils' describe('input types', () => { describe('textarea', () => { - it('throws with no form context', () => { - throws(() => mount(