mirror of
https://github.com/cupcakearmy/formhero.git
synced 2024-12-22 08:06:24 +00:00
v1
This commit is contained in:
parent
6fdba7086c
commit
80c114679b
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,12 +1,6 @@
|
|||||||
# Node
|
# Node
|
||||||
node_modules
|
node_modules
|
||||||
pnpm-lock.yaml
|
|
||||||
pnpm-debug.log
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Parcel
|
|
||||||
.cache
|
|
||||||
public
|
|
||||||
|
|
||||||
# Generated
|
# Generated
|
||||||
dist
|
dist
|
||||||
|
coverage
|
||||||
|
11
.prettierrc
11
.prettierrc
@ -1,5 +1,6 @@
|
|||||||
semi: false
|
{
|
||||||
singleQuote: true
|
"semi": false,
|
||||||
trailingComma: es5
|
"singleQuote": true,
|
||||||
tabWidth: 2
|
"trailingComma": "es5",
|
||||||
printWidth: 200
|
"printWidth": 200
|
||||||
|
}
|
||||||
|
40
docs/assets/index-1a9957bf.js
Normal file
40
docs/assets/index-1a9957bf.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1,61 @@
|
|||||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Form Hero</title><style>body{padding:1em;margin:0;font-family:Courier New,Courier,monospace}section{text-align:center;max-width:25em;margin:1em auto;padding:1em;background-color:#f3f3f3;border-radius:.5em;box-shadow:0 8px 16px -16px rgba(0,0,0,.5)}input{font-size:1.25em;display:block;padding:.5em 1em;margin-top:.5em;border-radius:1em;width:100%;outline:none;border:.15em solid #fff}input:focus,input:hover{border-color:#31def5}input[type=submit]{cursor:pointer}input[type=checkbox]{display:inline;width:auto}</style></head><body> <section> <h3>Open the console to see the submitted data</h3> </section> <section id="simple"></section> <section id="errors"></section> <section id="select"></section> <section id="custom"></section> <script src="/formhero/simple.2520a232.js"></script> <script src="/formhero/errorsAndValidation.1ec8a0d3.js"></script> <script src="/formhero/select.c810e4f2.js"></script> <script src="/formhero/custom.7cabc6c1.js"></script> </body></html>
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Form Hero</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 25em;
|
||||||
|
margin: 1em auto;
|
||||||
|
padding: 1em;
|
||||||
|
background-color: #f3f3f3;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
box-shadow: 0 8px 16px -16px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-size: 1.25em;
|
||||||
|
display: block;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
border-radius: 1em;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
border: 0.15em solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
input:hover {
|
||||||
|
border-color: #31def5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='submit'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
display: inline;
|
||||||
|
width: initial;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="module" crossorigin src="/assets/index-1a9957bf.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section>
|
||||||
|
<h3>Open the console to see the submitted data</h3>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
8
examples/common.tsx
Normal file
8
examples/common.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
export function mount(Node: React.FC) {
|
||||||
|
const section = window.document.createElement('section')
|
||||||
|
window.document.body.appendChild(section)
|
||||||
|
createRoot(section).render(<Node />)
|
||||||
|
}
|
@ -1,16 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
|
|
||||||
import { useForm } from '../dist'
|
import { useForm } from '../dist'
|
||||||
|
import { mount } from './common'
|
||||||
|
|
||||||
const Index: React.FC = () => {
|
const Index: React.FC = () => {
|
||||||
const { field, form, errors } = useForm({
|
const { field, form } = useForm({
|
||||||
awesome: true,
|
awesome: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
console.log(form)
|
console.log(form)
|
||||||
}}
|
}}
|
||||||
@ -23,7 +23,7 @@ const Index: React.FC = () => {
|
|||||||
{...field('awesome', {
|
{...field('awesome', {
|
||||||
setter: 'checked',
|
setter: 'checked',
|
||||||
getter: 'onChange',
|
getter: 'onChange',
|
||||||
extractor: e => e.target.checked,
|
extractor: (e) => e.target.checked,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
Is it awesome?
|
Is it awesome?
|
||||||
@ -34,4 +34,4 @@ const Index: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(<Index />, document.getElementById('custom'))
|
mount(Index)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
|
|
||||||
import { useForm } from '../'
|
import { useForm } from '../'
|
||||||
|
import { mount } from './common'
|
||||||
|
|
||||||
const Index: React.FC = () => {
|
const Index: React.FC = () => {
|
||||||
const { field, form, errors, isValid } = useForm(
|
const { field, form, errors, isValid } = useForm(
|
||||||
@ -11,27 +11,29 @@ const Index: React.FC = () => {
|
|||||||
password: '',
|
password: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: value => value.length > 3,
|
rules: {
|
||||||
|
username: (value) => value.length > 3,
|
||||||
email: {
|
email: {
|
||||||
validator: /@/,
|
rule: /@/,
|
||||||
message: 'Must contain an @',
|
message: 'Must contain an @',
|
||||||
},
|
},
|
||||||
password: [
|
password: [
|
||||||
{
|
{
|
||||||
validator: /[A-Z]/,
|
rule: /[A-Z]/,
|
||||||
message: 'Must contain an uppercase letter',
|
message: 'Must contain an uppercase letter',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
validator: /[\d]/,
|
rule: /[\d]/,
|
||||||
message: 'Must contain a digit',
|
message: 'Must contain a digit',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (isValid) console.log(form)
|
if (isValid) console.log(form)
|
||||||
}}
|
}}
|
||||||
@ -52,4 +54,4 @@ const Index: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(<Index />, document.getElementById('errors'))
|
mount(Index)
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
body {
|
body {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
@ -51,14 +51,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<h3>Open the console to see the submitted data</h3>
|
<h3>Open the console to see the submitted data</h3>
|
||||||
</section>
|
</section>
|
||||||
<section id="simple"></section>
|
|
||||||
<section id="errors"></section>
|
|
||||||
<section id="select"></section>
|
|
||||||
<section id="custom"></section>
|
|
||||||
|
|
||||||
<script src="./simple.tsx"></script>
|
<script type="module" src="./simple.tsx"></script>
|
||||||
<script src="./errorsAndValidation.tsx"></script>
|
<script type="module" src="./errorsAndValidation.tsx"></script>
|
||||||
<script src="./select.tsx"></script>
|
<script type="module" src="./select.tsx"></script>
|
||||||
<script src="./custom.tsx"></script>
|
<script type="module" src="./custom.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
|
|
||||||
import { useForm } from '../dist'
|
import { useForm } from '../dist'
|
||||||
|
import { mount } from './common'
|
||||||
|
|
||||||
const Index: React.FC = () => {
|
const Index: React.FC = () => {
|
||||||
const { field, form, errors } = useForm({
|
const { field, form } = useForm({
|
||||||
type: 'formhero',
|
type: 'formhero',
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
console.log(form)
|
console.log(form)
|
||||||
}}
|
}}
|
||||||
@ -29,4 +29,4 @@ const Index: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(<Index />, document.getElementById('select'))
|
mount(Index)
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
|
|
||||||
import { useForm } from '../dist'
|
import { useForm } from '../dist'
|
||||||
|
import { mount } from './common'
|
||||||
|
|
||||||
const Index: React.FC = () => {
|
const Index: React.FC = () => {
|
||||||
const { field, form, errors } = useForm({
|
const { field, form } = useForm({
|
||||||
username: 'unicorn',
|
username: 'unicorn',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
console.log(form)
|
console.log(form)
|
||||||
}}
|
}}
|
||||||
@ -26,4 +26,4 @@ const Index: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(<Index />, document.getElementById('simple'))
|
mount(Index)
|
||||||
|
131
lib/index.ts
131
lib/index.ts
@ -1,100 +1,95 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export type FieldOptions<G extends string = 'onChange', S extends string = 'value'> = {
|
||||||
|
extractor?: useFormExtractor
|
||||||
|
getter: G
|
||||||
|
setter: S
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuleFunctionReturn = boolean | string
|
||||||
|
type RuleFunction<I> = (value: I) => RuleFunctionReturn | Promise<RuleFunctionReturn>
|
||||||
|
type Rule<I> = RuleFunction<I> | RegExp
|
||||||
|
type RuleObject<I> = Rule<I> | { rule: Rule<I>; message: string }
|
||||||
|
type RuleSet<I> = RuleObject<I> | RuleObject<I>[]
|
||||||
|
|
||||||
|
function isSimpleRule<I>(obj: RuleObject<I>): obj is Rule<I> {
|
||||||
|
return obj instanceof RegExp || typeof obj === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
export type useFormExtractor = (from: any) => any
|
export type useFormExtractor = (from: any) => any
|
||||||
|
|
||||||
export type useFormOptions = {
|
|
||||||
extractor?: useFormExtractor
|
|
||||||
getter?: string
|
|
||||||
setter?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type useFormValidatorFunctionReturn = boolean | string
|
|
||||||
export type useFormValidatorFunction = (s: any) => useFormValidatorFunctionReturn | Promise<useFormValidatorFunctionReturn>
|
|
||||||
export type useFormValidatorMethod = useFormValidatorFunction | RegExp
|
|
||||||
|
|
||||||
export type useFormValidatorObject = {
|
|
||||||
validator: useFormValidatorMethod
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type useFormValidator = useFormValidatorMethod | useFormValidatorObject
|
|
||||||
|
|
||||||
export type useFormValidatorParameter = useFormValidator | useFormValidator[]
|
|
||||||
|
|
||||||
export const HTMLInputExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.value
|
export const HTMLInputExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.value
|
||||||
export const HTMLCheckboxExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.checked
|
export const HTMLCheckboxExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.checked
|
||||||
|
|
||||||
function isFormValidatorObject(validator: useFormValidatorMethod | useFormValidatorObject): validator is useFormValidatorObject {
|
export type FormOptions<R> = {
|
||||||
return validator.constructor.name === 'Object'
|
rules: R
|
||||||
|
// fields: FieldOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultErrorMessage = (key: any) => `Error in ${key}`
|
// Form = Type of form
|
||||||
|
// R = Rules, derived from F
|
||||||
|
// E = Errors, derived from F
|
||||||
|
export const useForm = <Form extends object, R extends { [K in keyof Form]?: RuleSet<Form[K]> }, E extends { [key in keyof R]?: RuleFunctionReturn }>(init: Form, options?: FormOptions<R>) => {
|
||||||
|
const validators: R = options?.rules ?? ({} as R)
|
||||||
|
|
||||||
export const useForm = <T extends object, U extends { [key in keyof T]: useFormValidatorParameter }, E extends { [key in keyof U]?: string }>(
|
const [form, setForm] = useState<Form>(init)
|
||||||
init: T,
|
const [errors, setErrors] = useState<E>({} as E)
|
||||||
validators: Partial<U> = {},
|
const [isValid, setIsValid] = useState<boolean>(true)
|
||||||
options: useFormOptions = {}
|
|
||||||
) => {
|
|
||||||
const [form, setForm] = useState<T>(init)
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<E>>({})
|
|
||||||
const [isValid, setIsValid] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsValid(!Object.values(errors).reduce((acc, cur) => acc || cur !== undefined, false))
|
setIsValid(!Object.values(errors).reduce((acc, cur) => acc || cur !== undefined, false))
|
||||||
}, [errors])
|
}, [errors])
|
||||||
|
|
||||||
const _set = <A extends keyof T>(key: A, value: T[A]) => {
|
const setField = <A extends keyof Form>(key: A, value: Form[A]) => {
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const _validateAll = async (value: any, object: useFormValidator): Promise<useFormValidatorFunctionReturn> => {
|
async function applyRule<I>(value: any, rule: Rule<I>): Promise<RuleFunctionReturn> {
|
||||||
const validator = isFormValidatorObject(object) ? object.validator : object
|
if (typeof rule === 'function') return await rule(value)
|
||||||
|
if (rule instanceof RegExp) return rule.test(value)
|
||||||
if (validator.constructor.name === 'Function') return (validator as useFormValidatorFunction)(value)
|
throw new Error(`Unsupported validator: ${rule}`)
|
||||||
else if (validator.constructor.name === 'AsyncFunction') return await (validator as useFormValidatorFunction)(value)
|
|
||||||
else if (validator.constructor.name === 'RegExp') return (validator as RegExp).test(value)
|
|
||||||
else return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _getErrorMessage = (result: useFormValidatorFunctionReturn, key: keyof T, validator: useFormValidatorMethod | useFormValidatorObject) =>
|
async function validate<K extends keyof Form>(key: K, value: Form[K]) {
|
||||||
result === true ? undefined : result.constructor.name === 'String' ? result : isFormValidatorObject(validator) && validator.message ? validator.message : defaultErrorMessage(key)
|
const set: RuleSet<Form[K]> | undefined = validators[key] as any
|
||||||
|
if (!set) return
|
||||||
|
|
||||||
const _validate = (key: keyof T, value: any) => {
|
const rules = Array.isArray(set) ? set : [set]
|
||||||
const validator: useFormValidatorParameter | undefined = validators[key]
|
let newValue = undefined
|
||||||
if (!validator) return
|
for (const rule of rules) {
|
||||||
|
const simple = isSimpleRule(rule)
|
||||||
if (Array.isArray(validator)) {
|
const fn = simple ? rule : rule.rule
|
||||||
Promise.all(validator.map(v => _validateAll(value, v))).then(results => {
|
const result = await applyRule(value, fn)
|
||||||
const i = results.findIndex(result => result !== true)
|
if (result !== true) {
|
||||||
|
newValue = simple ? (typeof result === 'string' ? result : true) : rule.message
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
setErrors({
|
setErrors({
|
||||||
...errors,
|
...errors,
|
||||||
[key]: i === -1 ? undefined : _getErrorMessage(results[i], key, validator[i]),
|
[key]: newValue,
|
||||||
})
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
_validateAll(value, validator).then(result => {
|
|
||||||
setErrors({
|
|
||||||
...errors,
|
|
||||||
[key]: _getErrorMessage(result, key, validator),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = <A extends keyof T>(key: A, extractor = options.extractor) => (value: T[A]) => {
|
function update<A extends keyof Form, RAW = any>(key: A, extractor?: (e: RAW) => Form[A]) {
|
||||||
|
return (value: RAW) => {
|
||||||
const extracted = extractor ? extractor(value) : HTMLInputExtractor(value)
|
const extracted = extractor ? extractor(value) : HTMLInputExtractor(value)
|
||||||
_set(key, extracted)
|
setField(key, extracted)
|
||||||
_validate(key, extracted)
|
validate(key, extracted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = (key: keyof T, opts: useFormOptions = {}) => ({
|
type FieldReturn<K extends keyof Form, G extends string, S extends string> = { [getter in G]: ReturnType<typeof update<K>> } & { [setter in S]: Form[K] }
|
||||||
[opts.getter || options.getter || 'onChange']: update(key, opts.extractor),
|
function field<K extends keyof Form>(key: K): FieldReturn<K, 'onChange', 'value'>
|
||||||
[opts.setter || options.setter || 'value']: form[key] as any,
|
function field<K extends keyof Form, G extends string, S extends string>(key: K, opts: FieldOptions<G, S>): FieldReturn<K, G, S>
|
||||||
})
|
function field<K extends keyof Form, G extends string, S extends string>(key: K, opts?: FieldOptions<G, S>): FieldReturn<K, G, S> {
|
||||||
|
return {
|
||||||
|
[opts?.getter || 'onChange']: update<K>(key, opts?.extractor),
|
||||||
|
[opts?.setter || 'value']: form[key],
|
||||||
|
} as FieldReturn<K, G, S>
|
||||||
|
}
|
||||||
|
|
||||||
return { form, update, field, errors, isValid, setForm, setErrors, setField: _set }
|
return { form, field, errors, isValid, setForm, setErrors, setField }
|
||||||
}
|
}
|
||||||
|
44
package.json
44
package.json
@ -1,31 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "formhero",
|
"name": "formhero",
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"main": "dist/index.js",
|
"type": "module",
|
||||||
"typings": "dist/index.d.ts",
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepublishOnly": "rm -rf ./dist && tsc",
|
"clean": "rm -rf ./dist",
|
||||||
"examples": "parcel -d public ./examples/index.html",
|
"prepublishOnly": "run-s clean build test",
|
||||||
"examples:build": "rm -rf ./docs && parcel build --no-source-maps --public-url /formhero/ -d docs examples/index.html",
|
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "parcel -d public ./test/index.html",
|
"build:watch": "tsc -w",
|
||||||
"watch": "tsc -w",
|
"demo": "vite build",
|
||||||
"dev": "pnpm run watch & pnpm run test"
|
"demo:watch": "vite",
|
||||||
|
"test": "vitest --coverage --run",
|
||||||
|
"test:watch": "vitest --coverage",
|
||||||
|
"dev": "run-p build:watch demo:watch test:watch"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
|
||||||
"last 2 Chrome versions",
|
|
||||||
"last 2 Firefox versions",
|
|
||||||
"last 2 Safari versions"
|
|
||||||
],
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8"
|
"react": ">=16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^16.9.3",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@types/react-dom": "^16.9.1",
|
"@types/react": "^18.0.27",
|
||||||
"parcel-bundler": "^1.12.3",
|
"@types/react-dom": "^18.0.10",
|
||||||
"react": "^16.9.0",
|
"@vitejs/plugin-react": "^3.0.1",
|
||||||
"react-dom": "^16.9.0",
|
"@vitest/coverage-c8": "^0.28.2",
|
||||||
"typescript": "^3.6.2"
|
"happy-dom": "^8.1.5",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "^4.0.4",
|
||||||
|
"vitest": "^0.28.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
2419
pnpm-lock.yaml
generated
Normal file
2419
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
154
test/basic.test.tsx
Normal file
154
test/basic.test.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { beforeEach, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
|
import { useForm } from '../lib'
|
||||||
|
|
||||||
|
beforeEach(cleanup)
|
||||||
|
|
||||||
|
const Insight = {
|
||||||
|
Portal({ data }: { data: any }) {
|
||||||
|
return <div data-testid="result">{JSON.stringify(data)}</div>
|
||||||
|
},
|
||||||
|
async verify(obj: any) {
|
||||||
|
const result = await screen.findByTestId('result')
|
||||||
|
const data = JSON.parse(result.innerText)
|
||||||
|
expect(data).toMatchObject(obj)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Util = {
|
||||||
|
find<E extends HTMLElement = HTMLInputElement>(id: string) {
|
||||||
|
return screen.findByTestId<E>(id)
|
||||||
|
},
|
||||||
|
writeToField(node: HTMLInputElement, value: string) {
|
||||||
|
fireEvent.change(node, { target: { value } })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Field', () => {
|
||||||
|
test('Basic Form', async () => {
|
||||||
|
const BasicForm = () => {
|
||||||
|
const form = useForm({ username: '', password: '' })
|
||||||
|
const { field } = form
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input data-testid="username" {...field('username')} />
|
||||||
|
<input data-testid="password" {...field('password')} />
|
||||||
|
<button data-testid="submit" type="submit">
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
<Insight.Portal data={form} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<BasicForm />)
|
||||||
|
async function inputIntoForm(id: string, value: string) {
|
||||||
|
const node = await Util.find(id)
|
||||||
|
await act(() => {
|
||||||
|
Util.writeToField(node, value)
|
||||||
|
})
|
||||||
|
await Insight.verify({ form: { [id]: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await inputIntoForm('username', 'foo')
|
||||||
|
await inputIntoForm('password', 'bar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setField', async () => {
|
||||||
|
const value = 'foo'
|
||||||
|
const Component = () => {
|
||||||
|
const { field, setField, form } = useForm({ username: '', password: '' })
|
||||||
|
useEffect(() => setField('username', value), [])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input data-testid="field" {...field('username')}></input>
|
||||||
|
<Insight.Portal data={form} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render(<Component />)
|
||||||
|
const node = await screen.findByTestId<HTMLInputElement>('field')
|
||||||
|
expect(node.value).toBe(value)
|
||||||
|
Insight.verify({ username: value, password: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Field sync', async () => {
|
||||||
|
const value = 'foo'
|
||||||
|
const Component = () => {
|
||||||
|
const { field, form } = useForm({ name: '' })
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<input {...field('name')} data-testid="a" />
|
||||||
|
<input {...field('name')} data-testid="b" />
|
||||||
|
<Insight.Portal data={form} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<Component />)
|
||||||
|
const a = await Util.find('a')
|
||||||
|
const b = await Util.find('b')
|
||||||
|
await act(() => {
|
||||||
|
Util.writeToField(a, value)
|
||||||
|
})
|
||||||
|
await Insight.verify({ name: value })
|
||||||
|
expect(a.value).toBe(b.value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Validation', () => {
|
||||||
|
test('Basic', async () => {
|
||||||
|
const Component = () => {
|
||||||
|
const { errors, field } = useForm({ password: '' }, { rules: { password: [(p) => p.length > 8] } })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input {...field('password')} data-testid="field" />
|
||||||
|
<Insight.Portal data={errors} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render(<Component />)
|
||||||
|
const node = await Util.find('field')
|
||||||
|
await act(() => {
|
||||||
|
Util.writeToField(node, '123')
|
||||||
|
})
|
||||||
|
Insight.verify({ password: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Array of rules', async () => {
|
||||||
|
const Component = () => {
|
||||||
|
const { errors, field } = useForm({ password: '' }, { rules: { password: [(p) => p.length > 8, /#/] } })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input {...field('password')} data-testid="field" />
|
||||||
|
<Insight.Portal data={errors} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render(<Component />)
|
||||||
|
const node = await Util.find('field')
|
||||||
|
await act(() => {
|
||||||
|
Util.writeToField(node, '12345678')
|
||||||
|
})
|
||||||
|
Insight.verify({ password: true })
|
||||||
|
await act(() => {
|
||||||
|
Util.writeToField(node, '1234#5678')
|
||||||
|
})
|
||||||
|
Insight.verify({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Is valid
|
||||||
|
// Reset / setForm
|
||||||
|
// Set error
|
||||||
|
// Checkbox
|
||||||
|
// Extractor
|
||||||
|
// Custom extractor
|
@ -1,19 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
|
||||||
<title>Form Hero</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.5/css/bulma.min.css" />
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="./test.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
2
test/setup.ts
Normal file
2
test/setup.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// https://github.com/testing-library/react-testing-library/issues/1061#issuecomment-1117450890
|
||||||
|
global.IS_REACT_ACT_ENVIRONMENT = true
|
112
test/test.tsx
112
test/test.tsx
@ -1,112 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
|
|
||||||
import { useForm } from '../'
|
|
||||||
|
|
||||||
const TextError: React.FC<{ error?: string }> = ({ error }) => (!error ? null : <div className="has-text-danger">{error}</div>)
|
|
||||||
|
|
||||||
const initial = {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
type: 'formhero',
|
|
||||||
awesome: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const Index: React.FC = () => {
|
|
||||||
const { field, form, errors, isValid, setForm, setErrors, setField } = useForm(initial, {
|
|
||||||
username: [
|
|
||||||
/^abc/,
|
|
||||||
{
|
|
||||||
validator: async (s: string) => {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
message: 'Async shit not working',
|
|
||||||
},
|
|
||||||
(s: string) => (s.includes('d') ? true : 'Needs the D'),
|
|
||||||
],
|
|
||||||
password: {
|
|
||||||
validator: /^.{3,}$/,
|
|
||||||
message: 'To short',
|
|
||||||
},
|
|
||||||
awesome: value => !!value,
|
|
||||||
})
|
|
||||||
|
|
||||||
const _submit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
console.log(form, errors, isValid)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setForm(initial)
|
|
||||||
setField('username', 'asdf')
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = () => {
|
|
||||||
setErrors({
|
|
||||||
username: 'nope',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<form onSubmit={_submit}>
|
|
||||||
<div>Username</div>
|
|
||||||
<input className="input" {...field('username')} />
|
|
||||||
<TextError error={errors.username} />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<div>Password</div>
|
|
||||||
<input className="input" {...field('password')} />
|
|
||||||
<TextError error={errors.password} />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<div>Which one to choose?</div>
|
|
||||||
<div className="select">
|
|
||||||
<select {...field('type')}>
|
|
||||||
<option value="redux-form">Redux-Form</option>
|
|
||||||
<option value="react-hook-forms">React-Hook-Forms</option>
|
|
||||||
<option value="formik">Formik</option>
|
|
||||||
<option value="formhero">FormHero</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<label className="checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
{...field('awesome', {
|
|
||||||
setter: 'checked',
|
|
||||||
getter: 'onChange',
|
|
||||||
extractor: e => e.target.checked,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
Is it awesome?
|
|
||||||
</label>
|
|
||||||
<TextError error={errors.awesome} />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<button className="button" type="submit">
|
|
||||||
Go 🚀
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<button className="button" onClick={reset}>
|
|
||||||
Reset 🔥
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<button className="button" onClick={error}>
|
|
||||||
Set Error ❌
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.render(<Index />, document.getElementById('root'))
|
|
@ -1,25 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"target": "ES2020",
|
||||||
"module": "commonjs",
|
"module": "ES2020",
|
||||||
|
"moduleResolution": "node",
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitAny": true,
|
"allowSyntheticDefaultImports": true
|
||||||
"strictNullChecks": true,
|
|
||||||
"strictFunctionTypes": true,
|
|
||||||
"strictBindCallApply": true,
|
|
||||||
"strictPropertyInitialization": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"alwaysStrict": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"emitDecoratorMetadata": true
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["./lib"]
|
||||||
"./lib"
|
|
||||||
]
|
|
||||||
}
|
}
|
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
root: './examples',
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: '../docs',
|
||||||
|
},
|
||||||
|
})
|
13
vitest.config.js
Normal file
13
vitest.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
setupFiles: ['./test/setup.ts'],
|
||||||
|
globals: false,
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user