7 Commits

Author SHA1 Message Date
37b4b9b990 chore: publish on beta channel correctly 2022-08-26 16:32:46 +02:00
7e3ef0f492 ci: runs on develop branch 2022-08-26 16:29:20 +02:00
c0034d5af6 chore: temporarly release new beta version on develop branch 2022-08-26 16:27:52 +02:00
694d31e68d ci: disable temporarly release 2022-08-26 16:13:13 +02:00
a2edafdc22 fix(hooks): usage of useForm 2022-08-26 00:30:54 +02:00
17656c149a feat: add form validation 2022-08-25 23:24:40 +02:00
c9bb631073 build(deps): update latest 2022-08-25 22:51:40 +02:00
23 changed files with 8050 additions and 5169 deletions

View File

@ -1,9 +1,11 @@
# For more information see: https://editorconfig.org/
root = true root = true
[*] [*]
charset = utf-8
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
end_of_line = lf end_of_line = lf
insert_final_newline = true charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true

View File

@ -2,9 +2,9 @@ name: 'Build'
on: on:
push: push:
branches: [master] branches: [master, develop]
pull_request: pull_request:
branches: [master] branches: [master, develop]
jobs: jobs:
build: build:

View File

@ -2,9 +2,9 @@ name: 'Lint'
on: on:
push: push:
branches: [master] branches: [master, develop]
pull_request: pull_request:
branches: [master] branches: [master, develop]
jobs: jobs:
lint: lint:

View File

@ -2,7 +2,7 @@ name: 'Release'
on: on:
push: push:
branches: [master] branches: [master, develop]
jobs: jobs:
build: build:
@ -22,15 +22,6 @@ jobs:
- name: 'Build Package' - name: 'Build Package'
run: 'npm run build' run: 'npm run build'
- name: 'Build Example'
run: 'cd example && npm install && npm run build'
- name: 'Deploy Example'
uses: 'JamesIves/github-pages-deploy-action@v4.3.0'
with:
branch: 'gh-pages'
folder: 'example/dist'
- name: 'Release' - name: 'Release'
run: 'npm run release' run: 'npm run release'
env: env:

View File

@ -2,9 +2,9 @@ name: 'Test'
on: on:
push: push:
branches: [master] branches: [master, develop]
pull_request: pull_request:
branches: [master] branches: [master, develop]
jobs: jobs:
test: test:

11
.markdownlint-cli2.jsonc Normal file
View File

@ -0,0 +1,11 @@
{
"config": {
"default": true,
"MD013": false,
"MD024": false,
"MD033": false,
"MD041": false
},
"globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules"]
}

View File

@ -1,6 +0,0 @@
{
"default": true,
"MD013": false,
"MD033": false,
"MD041": false
}

View File

@ -1,5 +1,8 @@
{ {
"branches": ["master"], "branches": [
"master",
{ "name": "develop", "prerelease": "beta", "channel": "beta" }
],
"plugins": [ "plugins": [
[ [
"@semantic-release/commit-analyzer", "@semantic-release/commit-analyzer",

View File

@ -22,7 +22,9 @@
**react-component-form** is a lightweight form component for [React.js](https://reactjs.org/), it allows you to get the inputs values without state thanks to `onChange` or `onSubmit` props. **react-component-form** is a lightweight form component for [React.js](https://reactjs.org/), it allows you to get the inputs values without state thanks to `onChange` or `onSubmit` props.
Demo : [https://divlo.github.io/react-component-form/](https://divlo.github.io/react-component-form/). There is also a [React Hooks](https://reactjs.org/docs/hooks-intro.html) to be used in combination with the `<Form />` component to validate the data with [Ajv JSON schema validator](https://ajv.js.org/), see [advanced usage](#%EF%B8%8F-advanced-usage).
Demo: [https://divlo.github.io/react-component-form/](https://divlo.github.io/react-component-form/).
## 💾 Install ## 💾 Install
@ -32,11 +34,14 @@ npm install --save react-component-form
## ⚙️ Usage ## ⚙️ Usage
_Note : The examples use TypeScript, but obviously you can use JavaScript. Be aware that `HandleForm` is the type definition for the `onChange` and `onSubmit` props._
```tsx ```tsx
import React from 'react' import React from 'react'
import { Form, HandleForm } from 'react-component-form' import { Form } from 'react-component-form'
import type { HandleForm } from 'react-component-form'
const Example = () => { export const Example = () => {
const handleSubmit: HandleForm = (formData, formElement) => { const handleSubmit: HandleForm = (formData, formElement) => {
console.log(formData) // { inputName: 'value of the input' } console.log(formData) // { inputName: 'value of the input' }
formElement.reset() formElement.reset()
@ -51,15 +56,55 @@ const Example = () => {
} }
``` ```
_Note : The example use TypeScript, but obviously you can use JavaScript. Be aware that `HandleForm` is the type definition for the `onChange` and `onSubmit` props._
Basically you have access to the same props of the HTML `form` tag in React, but the onSubmit and the onChange props are differents. Basically you have access to the same props of the HTML `form` tag in React, but the onSubmit and the onChange props are differents.
Instead to get the `event` param you get `formData` and `formElement` params : Instead to get the `event` param you get `formData` and `formElement` parameters:
- `formData`: It's an object where the keys are the name of your inputs and the current value. Behind the scene, it uses the [FormData](https://developer.mozilla.org/docs/Web/API/FormData) constructor. - `formData`: It's an object where the keys are the name of your inputs and the current value. Behind the scene, it uses the [FormData](https://developer.mozilla.org/docs/Web/API/FormData) constructor.
- `formElement`: It's the actual HTML form element in the DOM so for example you can access the `.reset()` method on a [HTMLFormElement](https://developer.mozilla.org/docs/Web/API/HTMLFormElement). - `formElement`: It's the actual HTML form element in the DOM so for example you can access the `.reset()` method on a [HTMLFormElement](https://developer.mozilla.org/docs/Web/API/HTMLFormElement).
## ⚙️ Advanced Usage
This example shows how to use the `<Form />` component with `useForm` hook to validate the data with [Ajv JSON schema validator](https://ajv.js.org/).
You can see a more detailled example in the [./example](./example) folder.
```tsx
import React from 'react'
import { Form, useForm } from 'react-component-form'
import type { HandleUseFormCallback } from 'react-component-form'
const schema = {
inputName: {
type: 'string',
minLength: 3,
maxLength: 20
}
}
export const Example = () => {
const { errors, handleUseForm } = useForm(schema)
const onSubmit: HandleUseFormCallback<typeof schema> = (
formData,
formElement
) => {
console.log(formData) // { inputName: 'value of the input validated' }
formElement.reset()
return null
}
return (
<Form onSubmit={handleUseForm(onSubmit)}>
<input type='text' name='inputName' />
{errors.inputName != null && <p>{errors.inputName[0].message}</p>}
<button type='submit'>Submit</button>
</Form>
)
}
```
## 💡 Contributing ## 💡 Contributing
Anyone can help to improve the project, submit a Feature Request, a bug report or Anyone can help to improve the project, submit a Feature Request, a bug report or

View File

@ -1,65 +1,38 @@
import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Form, HandleForm } from 'react-component-form' import React from 'react'
import { Form, useForm } from 'react-component-form'
import type { HandleUseFormCallback } from 'react-component-form'
import './index.css' const schema = {
import GitHubLogo from 'url:./github.jpg' inputName: {
type: 'string',
const App: React.FC = () => { minLength: 3,
const handleSubmit: HandleForm = (formData, formElement) => { maxLength: 20
console.clear()
console.log('onSubmit: ', formData)
formElement.reset()
} }
}
const handleChange: HandleForm = (formData) => { export const Example = () => {
console.log('onChange: ', formData) const { errors, handleUseForm } = useForm(schema)
const onSubmit: HandleUseFormCallback<typeof schema> = (
formData,
formElement
) => {
console.log(formData) // { inputName: 'value of the input validated' }
formElement.reset()
return null
} }
return ( return (
<div className='container'> <Form onSubmit={handleUseForm(onSubmit)}>
<h2>{'<Form />'}</h2> <input type='text' name='inputName' />
<h5 className='title-install'>npm install --save react-component-form</h5> {errors.inputName != null && <p>{errors.inputName[0].message}</p>}
<Form onSubmit={handleSubmit} onChange={handleChange}> <button type='submit'>Submit</button>
<div className='form-group'>
<label htmlFor='name'>Name :</label>
<input
className='form-control'
type='text'
name='name'
id='name'
placeholder='name'
/>
</div>
<button type='submit' className='btn btn-primary'>
Submit
</button>
</Form> </Form>
<div className='result-container'>
<h4>
Try the form and Inspect the console{' '}
<span role='img' aria-label='smiley'>
😃
</span>
</h4>
</div>
<div className='github-logo'>
<a
target='_blank'
rel='noopener noreferrer'
href='https://github.com/Divlo/react-component-form'
>
<img width='30px' alt='github' src={GitHubLogo} />
</a>
</div>
</div>
) )
} }
const container = document.getElementById('root') as HTMLElement const container = document.getElementById('root') as HTMLElement
const root = createRoot(container) const root = createRoot(container)
root.render(<App />) root.render(<Example />)

2733
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,14 +8,15 @@
}, },
"dependencies": { "dependencies": {
"react": "file:../node_modules/react", "react": "file:../node_modules/react",
"react-dom": "file:../node_modules/react-dom", "react-component-form": "file:..",
"react-component-form": "file:.." "react-dom": "file:../node_modules/react-dom"
}, },
"devDependencies": { "devDependencies": {
"@parcel/transformer-image": "2.4.1", "@parcel/transformer-image": "2.7.0",
"@types/react": "17.0.43", "@types/react": "18.0.17",
"@types/react-dom": "17.0.14", "@types/react-dom": "18.0.6",
"parcel": "2.4.1", "parcel": "2.7.0",
"typescript": "4.6.3" "process": "^0.11.10",
"typescript": "4.7.4"
} }
} }

9992
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@
"bugs": { "bugs": {
"url": "https://github.com/Divlo/react-component-form/issues" "url": "https://github.com/Divlo/react-component-form/issues"
}, },
"homepage": "https://github.com/Divlo/react-component-form#readme", "homepage": "https://github.com/Divlo/react-component-form",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"files": [ "files": [
@ -28,7 +28,7 @@
"test": "jest", "test": "jest",
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker", "lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint \"**/*.md\" --dot --ignore-path \".gitignore\"", "lint:markdown": "markdownlint-cli2",
"lint:typescript": "eslint \"**/*.{js,jsx,ts,tsx}\"", "lint:typescript": "eslint \"**/*.{js,jsx,ts,tsx}\"",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"", "lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"",
"release": "semantic-release" "release": "semantic-release"
@ -36,32 +36,38 @@
"peerDependencies": { "peerDependencies": {
"react": ">=16" "react": ">=16"
}, },
"dependencies": {
"@sinclair/typebox": "0.24.28",
"ajv": "8.11.0",
"ajv-formats": "2.1.1"
},
"devDependencies": { "devDependencies": {
"@commitlint/cli": "16.2.3", "@commitlint/cli": "17.0.3",
"@commitlint/config-conventional": "16.2.1", "@commitlint/config-conventional": "17.0.3",
"@testing-library/react": "13.0.0", "@testing-library/react": "13.3.0",
"@types/jest": "27.4.1", "@types/jest": "28.1.8",
"@types/react": "17.0.43", "@types/react": "18.0.17",
"@types/react-dom": "17.0.14", "@types/react-dom": "18.0.6",
"@typescript-eslint/eslint-plugin": "5.18.0", "@typescript-eslint/eslint-plugin": "5.35.1",
"@typescript-eslint/parser": "5.18.0", "@typescript-eslint/parser": "5.35.1",
"editorconfig-checker": "4.0.2", "editorconfig-checker": "4.0.2",
"eslint": "8.12.0", "esbuild": "0.15.5",
"eslint-config-conventions": "2.0.0", "esbuild-jest": "0.5.0",
"eslint": "8.22.0",
"eslint-config-conventions": "3.0.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.0.0", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-promise": "6.0.0", "eslint-plugin-promise": "6.0.1",
"eslint-plugin-unicorn": "42.0.0", "eslint-plugin-unicorn": "43.0.2",
"esbuild": "0.14.32", "jest": "29.0.0",
"esbuild-jest": "0.5.0", "jest-environment-jsdom": "29.0.0",
"jest": "27.5.1", "markdownlint-cli2": "0.5.1",
"markdownlint-cli": "0.31.1", "prettier": "2.7.1",
"prettier": "2.6.2", "react": "18.2.0",
"react": "18.0.0", "react-dom": "18.2.0",
"react-dom": "18.0.0", "semantic-release": "19.0.5",
"semantic-release": "19.0.2", "tsup": "6.2.2",
"tsup": "5.12.4", "typescript": "4.7.4"
"typescript": "4.6.3"
} }
} }

View File

@ -9,7 +9,7 @@ export type HandleForm = (
formElement: HTMLFormElement formElement: HTMLFormElement
) => void | Promise<void> ) => void | Promise<void>
export interface ReactFormProps interface ReactFormProps
extends Omit<React.HTMLProps<HTMLFormElement>, 'onSubmit' | 'onChange'> {} extends Omit<React.HTMLProps<HTMLFormElement>, 'onSubmit' | 'onChange'> {}
export interface FormProps extends ReactFormProps { export interface FormProps extends ReactFormProps {

View File

@ -0,0 +1,15 @@
import { useState } from 'react'
export const fetchState = ['idle', 'loading', 'error', 'success'] as const
export type FetchState = typeof fetchState[number]
export const useFetchState = (
initialFetchState: FetchState = 'idle'
): [
fetchState: FetchState,
setFetchState: React.Dispatch<React.SetStateAction<FetchState>>
] => {
const [fetchState, setFetchState] = useState<FetchState>(initialFetchState)
return [fetchState, setFetchState]
}

147
src/hooks/useForm.ts Normal file
View File

@ -0,0 +1,147 @@
import { useMemo, useState } from 'react'
import { SchemaOptions, Static, TObject, Type } from '@sinclair/typebox'
import type { ErrorObject } from 'ajv'
import type { HandleForm } from '../components/Form'
import { FetchState, useFetchState } from './useFetchState'
import { ajv } from '../utils/ajv'
import { handleCheckboxBoolean } from '../utils/handleCheckboxBoolean'
import { handleOptionalEmptyStringToNull } from '../utils/handleOptionalEmptyStringToNull'
export type Schema = SchemaOptions
export type Error = ErrorObject
export type ErrorsObject<K extends Schema> = {
[key in keyof Partial<K>]: Error[] | undefined
}
export type HandleUseFormCallback<K extends Schema> = (
formData: Static<TObject<K>>,
formElement: HTMLFormElement
) => Promise<Message<K> | null> | Message<K> | null
export type HandleUseForm<K extends Schema> = (
callback?: HandleUseFormCallback<K>
) => HandleForm
export interface GlobalMessage {
type: 'error' | 'success'
value?: string
properties?: undefined
}
export interface PropertiesMessage<K extends Schema> {
type: 'error'
value?: string
properties: { [key in keyof Partial<K>]: string }
}
export type Message<K extends Schema> = GlobalMessage | PropertiesMessage<K>
export interface UseFormResult<K extends Schema> {
handleUseForm: HandleUseForm<K>
readonly fetchState: FetchState
setFetchState: React.Dispatch<React.SetStateAction<FetchState>>
/**
* Global message of the form (not specific to a property).
*/
readonly message: string | null
setMessage: React.Dispatch<React.SetStateAction<string | null>>
/**
* Errors for each property.
*
* The array will always have at least one element (never empty) in case of errors.
*
* `undefined` means no errors.
*/
readonly errors: ErrorsObject<K>
}
export const useForm = <K extends Schema>(
validationSchema: K
): UseFormResult<typeof validationSchema> => {
const validationSchemaObject = useMemo(() => {
return Type.Object(validationSchema)
}, [validationSchema])
const [fetchState, setFetchState] = useFetchState()
const [message, setMessage] = useState<string | null>(null)
const [errors, setErrors] = useState<ErrorsObject<typeof validationSchema>>(
{} as any
)
const validate = useMemo(() => {
return ajv.compile(validationSchemaObject)
}, [validationSchemaObject])
const handleUseForm: HandleUseForm<typeof validationSchema> = (callback) => {
return async (formData, formElement) => {
setErrors({} as any)
setMessage(null)
formData = handleOptionalEmptyStringToNull(
formData,
validationSchemaObject.required
)
formData = handleCheckboxBoolean(formData, validationSchemaObject)
const isValid = validate(formData)
if (!isValid) {
setFetchState('error')
const errors: ErrorsObject<typeof validationSchema> = {} as any
for (const property in validationSchemaObject.properties) {
const errorsForProperty = validate.errors?.filter((error) => {
return error.instancePath === `/${property}`
})
errors[property as keyof typeof validationSchema] =
errorsForProperty != null && errorsForProperty.length > 0
? errorsForProperty
: undefined
}
setErrors(errors)
} else {
setErrors({} as any)
if (callback != null) {
setFetchState('loading')
const message = await callback(
formData as Static<TObject<typeof validationSchema>>,
formElement
)
if (message != null) {
const { value = null, type, properties } = message
setMessage(value)
setFetchState(type)
if (type === 'error') {
const propertiesErrors: ErrorsObject<typeof validationSchema> =
{} as any
for (const property in properties) {
propertiesErrors[property] = [
{
keyword: 'message',
message: properties[property],
instancePath: `/${property}`,
schemaPath: `#/properties/${property}/message`,
params: {},
data: formData[property]
}
]
}
setErrors(propertiesErrors)
}
}
}
}
}
}
return {
handleUseForm,
fetchState,
setFetchState,
message,
setMessage,
errors
}
}

4
src/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './components/Form'
export * from './hooks/useFetchState'
export * from './hooks/useForm'
export * from './utils/ajv'

25
src/utils/ajv.ts Normal file
View File

@ -0,0 +1,25 @@
import addFormats from 'ajv-formats'
import Ajv from 'ajv'
export const ajv = addFormats(
new Ajv({
allErrors: true,
verbose: true
}),
[
'date-time',
'time',
'date',
'email',
'hostname',
'ipv4',
'ipv6',
'uri',
'uri-reference',
'uuid',
'uri-template',
'json-pointer',
'relative-json-pointer',
'regex'
]
)

View File

@ -0,0 +1,25 @@
import type { TObject } from '@sinclair/typebox'
import type { ObjectAny } from './types'
export const handleCheckboxBoolean = (
object: ObjectAny,
validateSchemaObject: TObject<ObjectAny>
): ObjectAny => {
const booleanProperties: string[] = []
for (const property in validateSchemaObject.properties) {
const rule = validateSchemaObject.properties[property]
if (rule.type === 'boolean') {
booleanProperties.push(property)
}
}
for (const booleanProperty of booleanProperties) {
if (object[booleanProperty] == null) {
object[booleanProperty] =
validateSchemaObject.properties[booleanProperty].default
} else {
object[booleanProperty] = object[booleanProperty] === 'on'
}
}
return object
}

View File

@ -0,0 +1,17 @@
export const handleOptionalEmptyStringToNull = <K>(
object: K,
required: string[] = []
): K => {
return Object.fromEntries(
Object.entries(object).map(([key, value]) => {
if (
typeof value === 'string' &&
value.length === 0 &&
!required.includes(key)
) {
return [key, null]
}
return [key, value]
})
) as K
}

3
src/utils/types.ts Normal file
View File

@ -0,0 +1,3 @@
export interface ObjectAny {
[key: string]: any
}

View File

@ -1,7 +1,7 @@
import { defineConfig } from 'tsup' import { defineConfig } from 'tsup'
export default defineConfig({ export default defineConfig({
entry: ['src/index.tsx'], entry: ['src/index.ts'],
sourcemap: true, sourcemap: true,
clean: true, clean: true,
platform: 'browser', platform: 'browser',