feat: poc

This commit is contained in:
Chen Asraf
2022-11-10 22:49:31 +02:00
commit 1cf2e63dbc
16 changed files with 3042 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
[*]
tab_width = 2
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
templates/
scaffolds/
webpack.config.js
dist/

27
.eslintrc.js Normal file
View File

@@ -0,0 +1,27 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['react', '@typescript-eslint'],
rules: {
indent: ['error', 2],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single'],
semi: ['error', 'never'],
'@typescript-eslint/no-non-null-assertion': 'off',
},
}

130
.gitignore vendored Normal file
View File

@@ -0,0 +1,130 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
templates/
scaffolds/

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"printWidth": 100,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"overrides": [
{
"files": "*.md",
"options": {
"printWidth": 100,
"proseWrap": "always"
}
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"cSpell.words": [
"formplex"
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Chen Asraf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# FormPlex - React
Handle forms in your React apps with incredible ease.
FormPlex lets you handle React forms without the hassle.
## Example Form
```tsx
import React from 'react'
import { useForm } from 'formplex-react'
interface MyFormData {
companyName: string
employeesCount: number
revenueRange: '$0-$50K' | '$50-$500K' | '$500K-$1M' | '$1M+'
}
export const MyForm: React.FC = () => {
const { field, handleSubmit, isValid, errors } = useForm<MyFormData>({
onSubmit(values, e) {
console.log('Form submitted:', values)
fetch('/submit', { method: 'POST', body: JSON.stringify(values) })
},
})
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
{...field('companyName', {
minLength: 5,
maxLength: 50,
required: true,
})}
/>
{errors.companyName && <div style={{ color: 'red' }}>{errors.companyName.message}</div>}
</div>
<div>
<input
type="number"
{...field('employeesCount', {
parse: Number,
pattern: /^\d+$/,
required: true,
validate: (n) => (n < 1 ? 'Must have at least 1 employee.' : null),
})}
/>
{errors.employeesCount && (
<div style={{ color: 'red' }}>{errors.employeesCount.message}</div>
)}
</div>
<div>
<select {...field('revenueRange', { required: true })}>
<option value="$0-$50K">$0-$50K</option>
<option value="$50-$500K">$50-$500K</option>
<option value="$500K-$1M">$500K-$1M</option>
<option value="$1M+">$1M+</option>
</select>
{errors.revenueRange && <div style={{ color: 'red' }}>{errors.revenueRange.message}</div>}
</div>
<div>
<button type="submit" disabled={!isValid}>
Save
</button>
</div>
</form>
)
}
```

61
example/example.tsx Normal file
View File

@@ -0,0 +1,61 @@
import * as React from 'react'
import { useForm } from '../src/use-form'
interface MyFormData {
companyName: string
employeesCount: number
revenueRange: '$0-$50K' | '$50-$500K' | '$500K-$1M' | '$1M+'
}
export const MyForm: React.FC = () => {
const { field, handleSubmit, isValid, errors } = useForm<MyFormData>({
onSubmit(values, e) {
console.log('Form submitted', values)
fetch('/submit', { method: 'POST', body: JSON.stringify(values) })
},
})
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
{...field('companyName', {
minLength: 5,
maxLength: 50,
required: true,
})}
/>
{errors.companyName && <div style={{ color: 'red' }}>{errors.companyName.message}</div>}
</div>
<div>
<input
type="number"
{...field('employeesCount', {
parse: Number,
pattern: /^\d+$/,
required: true,
validate: (n) => (n < 1 ? 'Must have at least 1 employee.' : null),
})}
/>
{errors.employeesCount && (
<div style={{ color: 'red' }}>{errors.employeesCount.message}</div>
)}
</div>
<div>
<select {...field('revenueRange', { required: true })}>
<option value="$0-$50K">$0-$50K</option>
<option value="$50-$500K">$50-$500K</option>
<option value="$500K-$1M">$500K-$1M</option>
<option value="$1M+">$1M+</option>
</select>
{errors.revenueRange && <div style={{ color: 'red' }}>{errors.revenueRange.message}</div>}
</div>
<div>
<button type="submit" disabled={!isValid}>
Save
</button>
</div>
</form>
)
}

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "formplex-react",
"version": "0.1.0",
"description": "Incredibly easy & flexible React form hooks",
"main": "index.js",
"repository": "https://github.com/chenasraf/formplex-react",
"author": "Chen Asraf <contact@casraf.dev>",
"license": "MIT",
"scripts": {
"build": "webpack --mode=production --node-env=production",
"build:dev": "webpack --mode=development",
"build:prod": "webpack --mode=production --node-env=production",
"watch": "webpack --watch"
},
"dependencies": {
"react": ">16"
},
"devDependencies": {
"@types/react": "^18.0.25",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"copy-webpack-plugin": "^11.0.0",
"eslint": "^8.27.0",
"eslint-config-react": "^1.1.7",
"eslint-plugin-react": "^7.31.10",
"prettier": "^2.7.1",
"ts-loader": "^9.4.1",
"typedoc": "^0.23.20",
"typescript": "^4.8.4",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0"
},
"peerDependencies": {
"react": ">16"
}
}

1
src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './use-form'

399
src/use-form.ts Normal file
View File

@@ -0,0 +1,399 @@
import * as React from 'react'
/**
* Options for the `useForm` hook
*/
export interface UseFormOptions<T> {
/**
* The initial state of the form. This will cause the form inputs to be pre-filled with the given data.
* Any missing field will be empty.
*/
initialState?: Partial<T>
/**
* Callback that will be fired when the form is submitted. The callback will receive the form data.
*/
onSubmit?: (values: T, e: React.FormEvent<HTMLFormElement>) => void
/**
* Map of custom error messages for the default validation methods.
* @see {ErrorStrings}
*/
errorMessages?: Partial<ErrorStrings>
/**
* Decide when to auto validate the form. Defaults to `onChange`.
*
* `immediate` - Show validation errors as soon as the hook mounts.
*
* `onChange` - Show validations after the user has changed the input.
*
* `onBlur` - Show validations after the user has blurred the input.
*
* `never` - Show validations after the user has submitted the form only, or on manual trigger.
*/
autoValidateBehavior?: 'immediate' | 'onChange' | 'onBlur' | 'never'
}
export interface UseFormReturn<T> {
/**
* Register a field input named `key` to the form. This will return props that should be injected into the input.
* See each of the options for more information.
*/
field: <K extends keyof T, E>(key: K, options?: FieldOptions<T, K>) => FieldReturn<E>
/**
* A mapping of the error messages given for each field.
* Each property is the name of the field and the value is the error message, if any.
*
* If there is no error, the value will be `undefined`.
*/
errors: Partial<Record<keyof T, ErrorMessage>>
/**
* The current form data, after parsing.
*/
state: Partial<T>
/**
* The current form data, before parsing.
*/
rawState: Partial<Record<keyof T, string | string[] | number>>
/**
* Indicates whether the form is valid.
*/
isValid: boolean
/**
* A callback that will submit the form. This will also call `onSubmit` if it was provided.
* You should inject this into the form's `onSubmit` prop.
*/
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
/**
* Set multiple fields at once. This will cause the form to re-render.
*/
setValues: (values: Partial<T>) => void
/**
* Set a single field. This will cause the form to re-render.
*/
setValue: <K extends keyof T>(key: K, value: T[K]) => void
}
export interface FieldOptions<T, K extends keyof T = keyof T> {
/**
* If `true`, the field will be required.
*
* This will cause the form to be invalid if the field is empty
* and an error message will be provided in `errors`.
*/
required?: boolean
/**
* Minimum length (in characters) for the field.
*
* This will cause the form to be invalid if the field is shorter than the given length,
* and an error message will be provided in `errors`.
*/
minLength?: number
/**
* Maximum length (in characters) for the field.
*
* This will cause the form to be invalid if the field is longer than the given length,
* and an error message will be provided in `errors`.
*/
maxLength?: number
/**
* A regular expression that the field must match.
*
* This will cause the input to not update if the value does not match the given pattern.
* To cause a validation error for a pattern, use `validate` instead.
*/
pattern?: string | RegExp
/**
* Map of custom error messages for the default validation methods.
*/
errorMessages?: Partial<ErrorStrings>
/**
* A custom validation function for the field.
*
* If it returns an empty string, `null` or `undefined`, the field is valid.
*
* Otherwise, the field is invalid and the returned string will be the error message.
*/
validate?: (value: T[K]) => string | undefined | null
/**
* A custom parser for the field. This will be called when the field is updated, and will cause the `state` object
* to be updated with the result of this function.
*
* `rawState` will always contain the raw value of the field as it was placed here.
*/
parse?: (value: string) => T[K]
/**
* A callback for changing the input, which also contains the parsed value.
*
* Never use `onChange` on the input field directly, or it will not work to update the form state.
* Use this callback instead, which acts right after the input's `onChange` callback.
*/
onChange?: (event: ChangeEvent, value: T[K]) => void
}
interface ErrorMessage {
/**
* The type of validation error on the field, such as `required`, `minLength`, `maxLength`, and `pattern`, or
* `validate` for custom validations
*/
error: keyof ErrorStrings | 'validate'
/**
* The error message for the field.
*/
message: string
}
/** @internal */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type FieldReturn<E> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any
required?: boolean
onChange: (event: ChangeEvent) => void
onBlur: (event: ChangeEvent) => void
}
/** @internal */
type ChangeEvent = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
target: any
}
/** @internal */
type BlurEvent = ChangeEvent
/**
* Map of custom error messages for the default validation methods.
*/
export interface ErrorStrings {
/**
* Error message for when the field is required but missing.
*
* Default: `"Required"`
*/
required: string
/**
* Error message for when the field length is too short.
*
* Can either be a string, or a function resolving to a string.
*
* Default:
* ```ts
* (n) => `Must be at least ${n} characters long`
* ```
*/
minLength: string | ((length: number) => string)
/**
* Error message for when the field length is too long.
*
* Can either be a string, or a function resolving to a string.
*
* Default:
* ```ts
* (n) => `Must be no more than ${n} characters long`
* ```
*/
maxLength: string | ((length: number) => string)
/**
* Error message for when the field value does not match the given pattern.
*
* Can either be a string, or a function resolving to a string.
*
* Default:
* ```ts
* (p) => `Must match pattern ${p}`
* ```
*/
pattern: string | ((pattern: string | RegExp) => string)
}
/** The main hook for using forms. See each option and return property for more information. */
export function useForm<T>({
initialState = {},
errorMessages = {},
autoValidateBehavior = 'onChange',
onSubmit,
}: UseFormOptions<T> = {}): UseFormReturn<T> {
errorMessages = {
required: 'Required',
minLength: (n) => `Must be at least ${n} characters long`,
maxLength: (n) => `Must be no more than ${n} characters long`,
pattern: (p) => `Must match pattern ${p}`,
...errorMessages,
}
const [state, setState] = React.useState<Partial<T>>(initialState ?? {})
const [rawState, setRawState] = React.useState<
Partial<Record<keyof T, string | string[] | number>>
>(
Object.entries(state).reduce(
(acc, [key, value]) => ({ ...acc, [key]: String(value) }),
{} as Partial<Record<keyof T, string | string[] | number>>,
),
)
const [errors, setErrors] = React.useState<Partial<Record<keyof T, ErrorMessage>>>({})
const isValid = React.useMemo(() => !Object.values(errors).some(Boolean), [errors])
function setValue<K extends keyof T>(
key: K,
value: T[K] | string | string[] | number,
raw: T[K] | string | string[] | number = value,
) {
setRawState((prev) => ({ ...prev, [key]: raw }))
setState((s) => ({ ...s, [key]: value }))
}
function validate<K extends keyof T>(
key: K,
value: T[K],
options: FieldOptions<T, K>,
): ErrorMessage | undefined {
const parseStr = (o: string | ((...args: unknown[]) => string), val: unknown) =>
typeof o === 'function' ? o(val) : o
const errorStrings = {
...errorMessages,
...(options?.errorMessages ?? {}),
} as ErrorStrings
// Required
if (options?.required && !value) {
return { error: 'required', message: errorStrings.required }
}
// Min length
if (options?.minLength && value && (value as string | string[]).length < options.minLength) {
return { error: 'minLength', message: parseStr(errorStrings.minLength, options.minLength) }
}
// Max length
if (options?.maxLength && value && (value as string | string[]).length > options.maxLength) {
return { error: 'maxLength', message: parseStr(errorStrings.maxLength, options.maxLength) }
}
// Custom validations
const validRes = options?.validate?.(value as T[K])
if (![undefined, null, ''].includes(validRes)) {
return { error: 'validate', message: validRes }
}
return undefined
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function parseValue<K extends keyof T, E extends HTMLElement = HTMLInputElement>(
value: string | number | string[] | null | undefined,
options?: FieldOptions<T, K>,
): string | number | string[] | undefined | T[K] {
const parsed = options?.parse ? options.parse(value as string) : value
if (value !== '' && options?.pattern) {
if (!new RegExp(options.pattern).test(parsed as string)) {
return
}
}
return parsed!
}
function fieldProps<K extends keyof T, E>(key: K, options?: FieldOptions<T, K>): FieldReturn<E> {
if (autoValidateBehavior === 'immediate') {
setErrorsFromRaw<K>(key, rawState[key], options)
}
return {
value: rawState[key] ?? '',
onChange: (e: ChangeEvent) => {
const value = parseValue(e.target.value, options)
if (value === undefined) {
return
}
setValueFromRaw<K>(key, e.target.value, options)
if (autoValidateBehavior === 'onChange') {
if (setErrorsFromRaw(key, e.target.value, options)) {
options?.onChange?.(e, value as T[K])
}
}
},
onBlur: (e: BlurEvent) => {
const value = parseValue(e.target.value, options)
if (value === undefined) {
return
}
setValueFromRaw<K>(key, e.target.value, options)
if (autoValidateBehavior === 'onBlur') {
if (setErrorsFromRaw(key, e.target.value, options)) {
options?.onChange?.(e, value as T[K])
}
}
},
}
}
function setErrorsFromRaw<K extends keyof T>(
key: K,
value: string | string[] | number,
options: FieldOptions<T, K>,
): boolean {
const parsed = parseValue(value, options)
const error = validate(key, parsed as T[K], options ?? {})
if (error) {
setErrors((e) => ({ ...e, [key]: error }))
return false
}
setErrors((e) => {
const copy = { ...e }
delete copy[key]
return copy
})
return true
}
function setValueFromRaw<K extends keyof T>(
key: K,
value: string | string[] | number,
options: FieldOptions<T, K>,
) {
const parsed = parseValue(value, options)
setValue(key, parsed as T[K], value)
}
function setValues(values: Partial<T>): void {
Object.entries(values).forEach(([key, value]) => setValue(key as keyof T, value as T[keyof T]))
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>): void {
e.preventDefault()
if (isValid) {
onSubmit?.(state as T, e)
}
}
return {
field: fieldProps,
errors,
state,
rawState,
isValid,
setValue,
setValues: setValues,
handleSubmit,
}
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"module": "CommonJS",
"target": "es5",
"allowJs": true,
"moduleResolution": "node",
"removeComments": false,
"declaration": true,
"declarationDir": "dist"
// "outDir": "dist",
},
"files": [
"src/index.ts"
]
}

51
webpack.config.js Normal file
View File

@@ -0,0 +1,51 @@
// Generated using webpack-cli https://github.com/webpack/webpack-cli
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin')
const isProduction = process.env.NODE_ENV == 'production'
/** @type {import('webpack').WebpackOptionsNormalized} */
const config = {
mode: isProduction ? 'production' : 'development',
entry: {
index: './src/index.ts',
},
output: {
path: path.resolve(__dirname, 'dist'),
library: {
type: 'commonjs2',
},
},
plugins: [
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
new CopyPlugin({
patterns: [{ from: 'package.json', to: '.' }],
}),
],
module: {
rules: [
{
test: /\.(ts|tsx)$/i,
loader: 'ts-loader',
exclude: ['/node_modules/'],
},
// {
// test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
// type: "asset",
// },
// Add your rules for custom modules here
// Learn more about loaders from https://webpack.js.org/loaders/
],
},
externals: {
react: 'commonjs react',
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
}
module.exports = config

2193
yarn.lock Normal file

File diff suppressed because it is too large Load Diff