feat: add form validation

This commit is contained in:
Divlo
2022-08-25 23:24:40 +02:00
parent c9bb631073
commit 17656c149a
13 changed files with 333 additions and 31 deletions

View File

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

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

@ -0,0 +1,144 @@
import { useMemo, useState } from 'react'
import { Static, TObject, TProperties, 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 Error = ErrorObject
export type ErrorsObject<K extends TProperties> = {
[key in keyof Partial<K>]: Error[] | undefined
}
export type HandleSubmitCallback<K extends TProperties> = (
formData: Static<TObject<K>>,
formElement: HTMLFormElement
) => Promise<Message<K> | null>
export type HandleSubmit<K extends TProperties> = (
callback: HandleSubmitCallback<K>
) => HandleForm
export interface GlobalMessage {
type: 'error' | 'success'
value?: string
properties?: undefined
}
export interface PropertiesMessage<K extends TProperties> {
type: 'error'
value?: string
properties: { [key in keyof Partial<K>]: string }
}
export type Message<K extends TProperties> =
| GlobalMessage
| PropertiesMessage<K>
export interface UseFormResult<K extends TProperties> {
handleSubmit: HandleSubmit<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 TProperties>(
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 handleSubmit: HandleSubmit<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)
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: {}
}
]
}
setErrors(propertiesErrors)
}
}
}
}
}
return {
handleSubmit,
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'

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

@ -0,0 +1,24 @@
import addFormats from 'ajv-formats'
import Ajv from 'ajv'
export const ajv = addFormats(
new Ajv({
allErrors: 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
}