This commit is contained in:
Niccolo Borgioli 2023-01-31 01:26:27 +01:00
parent 6fdba7086c
commit 80c114679b
No known key found for this signature in database
GPG Key ID: D93C615F75EE4F0B
24 changed files with 2865 additions and 385 deletions

10
.gitignore vendored
View File

@ -1,12 +1,6 @@
# Node
node_modules
pnpm-lock.yaml
pnpm-debug.log
package-lock.json
# Parcel
.cache
public
# Generated
dist
dist
coverage

View File

@ -1,5 +1,6 @@
semi: false
singleQuote: true
trailingComma: es5
tabWidth: 2
printWidth: 200
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 200
}

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

View File

@ -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
View 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 />)
}

View File

@ -1,16 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useForm } from '../dist'
import { mount } from './common'
const Index: React.FC = () => {
const { field, form, errors } = useForm({
const { field, form } = useForm({
awesome: true,
})
return (
<form
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault()
console.log(form)
}}
@ -23,7 +23,7 @@ const Index: React.FC = () => {
{...field('awesome', {
setter: 'checked',
getter: 'onChange',
extractor: e => e.target.checked,
extractor: (e) => e.target.checked,
})}
/>
Is it awesome?
@ -34,4 +34,4 @@ const Index: React.FC = () => {
)
}
ReactDOM.render(<Index />, document.getElementById('custom'))
mount(Index)

View File

@ -1,7 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useForm } from '../'
import { mount } from './common'
const Index: React.FC = () => {
const { field, form, errors, isValid } = useForm(
@ -11,27 +11,29 @@ const Index: React.FC = () => {
password: '',
},
{
username: value => value.length > 3,
email: {
validator: /@/,
message: 'Must contain an @',
rules: {
username: (value) => value.length > 3,
email: {
rule: /@/,
message: 'Must contain an @',
},
password: [
{
rule: /[A-Z]/,
message: 'Must contain an uppercase letter',
},
{
rule: /[\d]/,
message: 'Must contain a digit',
},
],
},
password: [
{
validator: /[A-Z]/,
message: 'Must contain an uppercase letter',
},
{
validator: /[\d]/,
message: 'Must contain a digit',
},
],
}
)
return (
<form
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault()
if (isValid) console.log(form)
}}
@ -52,4 +54,4 @@ const Index: React.FC = () => {
)
}
ReactDOM.render(<Index />, document.getElementById('errors'))
mount(Index)

View File

@ -7,7 +7,7 @@
body {
padding: 1em;
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 {
@ -51,14 +51,10 @@
<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="./simple.tsx"></script>
<script src="./errorsAndValidation.tsx"></script>
<script src="./select.tsx"></script>
<script src="./custom.tsx"></script>
<script type="module" src="./simple.tsx"></script>
<script type="module" src="./errorsAndValidation.tsx"></script>
<script type="module" src="./select.tsx"></script>
<script type="module" src="./custom.tsx"></script>
</body>
</html>

View File

@ -1,16 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useForm } from '../dist'
import { mount } from './common'
const Index: React.FC = () => {
const { field, form, errors } = useForm({
const { field, form } = useForm({
type: 'formhero',
})
return (
<form
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault()
console.log(form)
}}
@ -29,4 +29,4 @@ const Index: React.FC = () => {
)
}
ReactDOM.render(<Index />, document.getElementById('select'))
mount(Index)

View File

@ -1,17 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useForm } from '../dist'
import { mount } from './common'
const Index: React.FC = () => {
const { field, form, errors } = useForm({
const { field, form } = useForm({
username: 'unicorn',
password: '',
})
return (
<form
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault()
console.log(form)
}}
@ -26,4 +26,4 @@ const Index: React.FC = () => {
)
}
ReactDOM.render(<Index />, document.getElementById('simple'))
mount(Index)

View File

@ -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 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 HTMLCheckboxExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.checked
function isFormValidatorObject(validator: useFormValidatorMethod | useFormValidatorObject): validator is useFormValidatorObject {
return validator.constructor.name === 'Object'
export type FormOptions<R> = {
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 }>(
init: T,
validators: Partial<U> = {},
options: useFormOptions = {}
) => {
const [form, setForm] = useState<T>(init)
const [errors, setErrors] = useState<Partial<E>>({})
const [isValid, setIsValid] = useState(true)
const [form, setForm] = useState<Form>(init)
const [errors, setErrors] = useState<E>({} as E)
const [isValid, setIsValid] = useState<boolean>(true)
useEffect(() => {
setIsValid(!Object.values(errors).reduce((acc, cur) => acc || cur !== undefined, false))
}, [errors])
const _set = <A extends keyof T>(key: A, value: T[A]) => {
const setField = <A extends keyof Form>(key: A, value: Form[A]) => {
setForm({
...form,
[key]: value,
})
}
const _validateAll = async (value: any, object: useFormValidator): Promise<useFormValidatorFunctionReturn> => {
const validator = isFormValidatorObject(object) ? object.validator : object
if (validator.constructor.name === 'Function') return (validator as useFormValidatorFunction)(value)
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
async function applyRule<I>(value: any, rule: Rule<I>): Promise<RuleFunctionReturn> {
if (typeof rule === 'function') return await rule(value)
if (rule instanceof RegExp) return rule.test(value)
throw new Error(`Unsupported validator: ${rule}`)
}
const _getErrorMessage = (result: useFormValidatorFunctionReturn, key: keyof T, validator: useFormValidatorMethod | useFormValidatorObject) =>
result === true ? undefined : result.constructor.name === 'String' ? result : isFormValidatorObject(validator) && validator.message ? validator.message : defaultErrorMessage(key)
async function validate<K extends keyof Form>(key: K, value: Form[K]) {
const set: RuleSet<Form[K]> | undefined = validators[key] as any
if (!set) return
const _validate = (key: keyof T, value: any) => {
const validator: useFormValidatorParameter | undefined = validators[key]
if (!validator) return
const rules = Array.isArray(set) ? set : [set]
let newValue = undefined
for (const rule of rules) {
const simple = isSimpleRule(rule)
const fn = simple ? rule : rule.rule
const result = await applyRule(value, fn)
if (result !== true) {
newValue = simple ? (typeof result === 'string' ? result : true) : rule.message
break
}
}
setErrors({
...errors,
[key]: newValue,
})
}
if (Array.isArray(validator)) {
Promise.all(validator.map(v => _validateAll(value, v))).then(results => {
const i = results.findIndex(result => result !== true)
setErrors({
...errors,
[key]: i === -1 ? undefined : _getErrorMessage(results[i], key, validator[i]),
})
})
} else {
_validateAll(value, validator).then(result => {
setErrors({
...errors,
[key]: _getErrorMessage(result, key, validator),
})
})
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)
setField(key, extracted)
validate(key, extracted)
}
}
const update = <A extends keyof T>(key: A, extractor = options.extractor) => (value: T[A]) => {
const extracted = extractor ? extractor(value) : HTMLInputExtractor(value)
_set(key, extracted)
_validate(key, extracted)
type FieldReturn<K extends keyof Form, G extends string, S extends string> = { [getter in G]: ReturnType<typeof update<K>> } & { [setter in S]: Form[K] }
function field<K extends keyof Form>(key: K): FieldReturn<K, 'onChange', 'value'>
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>
}
const field = (key: keyof T, opts: useFormOptions = {}) => ({
[opts.getter || options.getter || 'onChange']: update(key, opts.extractor),
[opts.setter || options.setter || 'value']: form[key] as any,
})
return { form, update, field, errors, isValid, setForm, setErrors, setField: _set }
return { form, field, errors, isValid, setForm, setErrors, setField }
}

View File

@ -1,31 +1,35 @@
{
"name": "formhero",
"version": "0.0.7",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"prepublishOnly": "rm -rf ./dist && tsc",
"examples": "parcel -d public ./examples/index.html",
"examples:build": "rm -rf ./docs && parcel build --no-source-maps --public-url /formhero/ -d docs examples/index.html",
"build": "tsc",
"test": "parcel -d public ./test/index.html",
"watch": "tsc -w",
"dev": "pnpm run watch & pnpm run test"
},
"browserslist": [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions"
],
"peerDependencies": {
"react": "^16.8"
},
"devDependencies": {
"@types/react": "^16.9.3",
"@types/react-dom": "^16.9.1",
"parcel-bundler": "^1.12.3",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"typescript": "^3.6.2"
}
}
"name": "formhero",
"version": "0.0.7",
"type": "module",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"clean": "rm -rf ./dist",
"prepublishOnly": "run-s clean build test",
"build": "tsc",
"build:watch": "tsc -w",
"demo": "vite build",
"demo:watch": "vite",
"test": "vitest --coverage --run",
"test:watch": "vitest --coverage",
"dev": "run-p build:watch demo:watch test:watch"
},
"peerDependencies": {
"react": ">=16"
},
"devDependencies": {
"@testing-library/react": "^13.4.0",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^3.0.1",
"@vitest/coverage-c8": "^0.28.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

File diff suppressed because it is too large Load Diff

154
test/basic.test.tsx Normal file
View 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

View File

@ -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
View File

@ -0,0 +1,2 @@
// https://github.com/testing-library/react-testing-library/issues/1061#issuecomment-1117450890
global.IS_REACT_ACT_ENVIRONMENT = true

View File

@ -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'))

View File

@ -1,25 +1,13 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"jsx": "react",
"outDir": "./dist",
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [
"./lib"
]
}
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"jsx": "react",
"outDir": "./dist",
"declaration": true,
"strict": true,
"allowSyntheticDefaultImports": true
},
"include": ["./lib"]
}

11
vite.config.js Normal file
View 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
View 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',
},
})