Skip to content

Commit 3b216bf

Browse files
committed
feat: useForm hook
1 parent 11a77d5 commit 3b216bf

File tree

6 files changed

+277
-104
lines changed

6 files changed

+277
-104
lines changed

src/Common/Helper.tsx

Lines changed: 0 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -193,110 +193,6 @@ export function getCookie(sKey) {
193193
)
194194
}
195195

196-
export function useForm(stateSchema, validationSchema = {}, callback) {
197-
const [state, setState] = useState(stateSchema)
198-
const [disable, setDisable] = useState(true)
199-
const [isDirty, setIsDirty] = useState(false)
200-
201-
// Disable button in initial render.
202-
useEffect(() => {
203-
setDisable(true)
204-
}, [])
205-
206-
// For every changed in our state this will be fired
207-
// To be able to disable the button
208-
useEffect(() => {
209-
if (isDirty) {
210-
setDisable(validateState(state))
211-
}
212-
}, [state, isDirty])
213-
214-
// Used to disable submit button if there's an error in state
215-
// or the required field in state has no value.
216-
// Wrapped in useCallback to cached the function to avoid intensive memory leaked
217-
// in every re-render in component
218-
const validateState = useCallback(
219-
(state) => {
220-
// check errors in all fields
221-
const hasErrorInState = Object.keys(validationSchema).some((key) => {
222-
const isInputFieldRequired = validationSchema[key].required
223-
const stateValue = state[key].value // state value
224-
const stateError = state[key].error // state error
225-
return (isInputFieldRequired && !stateValue) || stateError
226-
})
227-
return hasErrorInState
228-
},
229-
[state, validationSchema],
230-
)
231-
232-
function validateField(name, value): string | string[] {
233-
if (validationSchema[name].required) {
234-
if (!value) {
235-
return 'This is a required field.'
236-
}
237-
}
238-
239-
function _validateSingleValidator(validator, value) {
240-
if (value && !validator.regex.test(value)) {
241-
return false
242-
}
243-
return true
244-
}
245-
246-
// single validator
247-
const _validator = validationSchema[name].validator
248-
if (_validator && typeof _validator === 'object') {
249-
if (!_validateSingleValidator(_validator, value)) {
250-
return _validator.error
251-
}
252-
}
253-
254-
// multiple validators
255-
const _validators = validationSchema[name].validators
256-
if (_validators && typeof _validators === 'object' && Array.isArray(_validators)) {
257-
const errors = []
258-
_validators.forEach((_validator) => {
259-
if (!_validateSingleValidator(_validator, value)) {
260-
errors.push(_validator.error)
261-
}
262-
})
263-
if (errors.length > 0) {
264-
return errors
265-
}
266-
}
267-
268-
return ''
269-
}
270-
271-
const handleOnChange = useCallback(
272-
(event) => {
273-
setIsDirty(true)
274-
275-
const { name, value } = event.target
276-
const error = validateField(name, value)
277-
setState((prevState) => ({
278-
...prevState,
279-
[name]: { value, error },
280-
}))
281-
},
282-
[validationSchema],
283-
)
284-
285-
const handleOnSubmit = (event) => {
286-
event.preventDefault()
287-
const newState = Object.keys(validationSchema).reduce((agg, curr) => {
288-
agg[curr] = { ...state[curr], error: validateField(curr, state[curr].value) }
289-
return agg
290-
}, state)
291-
if (!validateState(newState)) {
292-
callback(state)
293-
} else {
294-
setState({ ...newState })
295-
}
296-
}
297-
return { state, disable, handleOnChange, handleOnSubmit }
298-
}
299-
300196
export function handleUTCTime(ts: string, isRelativeTime = false) {
301197
let timestamp = ''
302198
try {

src/Shared/Hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
export * from './UsePrompt'
1818
export * from './useGetResourceKindsOptions'
1919
export * from './UseDownload'
20+
export * from './useForm'

src/Shared/Hooks/useForm/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './useForm'
2+
export * from './useForm.types'

src/Shared/Hooks/useForm/useForm.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { ChangeEvent, FormEvent, useState } from 'react'
2+
3+
import { checkValidation } from './useForm.utils'
4+
import {
5+
DirtyFields,
6+
UseFormErrors,
7+
TouchedFields,
8+
UseFormSubmitHandler,
9+
UseFormValidation,
10+
UseFormValidations,
11+
} from './useForm.types'
12+
13+
/**
14+
* A custom hook to manage form state, validation, and submission handling.
15+
*
16+
* @template T - A record type representing form data.
17+
* @param options (optional) - Options for initial form values, validation rules, and form mode.
18+
* @param options.validations - An object containing validation rules for form fields.
19+
* @param options.initialValues - An object representing the initial values of the form.
20+
* @param options.mode (default: `onChange`) - A string to set validation mode, either 'onChange' or 'onBlur'.
21+
* @returns Returns form state, handlers for change and submission, validation errors, and a trigger function for manual validation.
22+
*/
23+
export const useForm = <T extends Record<keyof T, any> = {}>(options?: {
24+
validations?: UseFormValidations<T>
25+
initialValues?: Partial<T>
26+
mode?: 'onChange' | 'onBlur'
27+
}) => {
28+
const [data, setData] = useState<T>((options?.initialValues || {}) as T)
29+
const [dirtyFields, setDirtyFields] = useState<DirtyFields<T>>({})
30+
const [touchedFields, setTouchedFields] = useState<TouchedFields<T>>({})
31+
const [errors, setErrors] = useState<UseFormErrors<T>>({})
32+
33+
/**
34+
* Handles change events for form inputs, updates the form data, and triggers validation.
35+
*
36+
* @template S - The sanitized value type.
37+
* @param key - The key of the form field to be updated.
38+
* @param sanitizeFn - An optional function to sanitize the input value.
39+
* @returns The event handler for input changes.
40+
*/
41+
const onChange =
42+
<S extends unknown>(key: keyof T, sanitizeFn?: (value: string) => S) =>
43+
(e: ChangeEvent<HTMLInputElement & HTMLSelectElement>) => {
44+
const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value
45+
setData({
46+
...data,
47+
[key]: value,
48+
})
49+
setTouchedFields({
50+
...data,
51+
[key]: true,
52+
})
53+
54+
if (!options?.mode || options?.mode === 'onChange' || dirtyFields[key]) {
55+
const validations = options?.validations ?? {}
56+
const error = checkValidation<T>(value as T[keyof T], validations[key as string])
57+
setErrors({ ...errors, [key]: error })
58+
}
59+
}
60+
61+
/**
62+
* Handles blur events for form inputs and triggers validation if the form mode is 'onBlur'.
63+
*
64+
* @param key - The key of the form field to be validated on blur.
65+
* @returns The event handler for the blur event.
66+
*/
67+
const onBlur = (key: keyof T) => () => {
68+
setDirtyFields({ ...dirtyFields, [key]: options?.initialValues?.[key] === data[key] })
69+
if (options?.mode === 'onBlur') {
70+
const validations = options?.validations ?? {}
71+
const error = checkValidation<T>(data[key] as T[keyof T], validations[key as string])
72+
setErrors({ ...errors, [key]: error })
73+
}
74+
}
75+
76+
/**
77+
* Handles form submission, validates all form fields, and calls the provided `onValid` function if valid.
78+
*
79+
* @param onValid - A function to handle valid form data on submission.
80+
* @returns The event handler for form submission.
81+
*/
82+
const handleSubmit = (onValid: UseFormSubmitHandler<T>) => (e: FormEvent<HTMLFormElement>) => {
83+
e.preventDefault()
84+
const validations = options?.validations
85+
if (validations) {
86+
const newErrors: UseFormErrors<T> = {}
87+
88+
Object.keys(validations).forEach((key) => {
89+
const validation: UseFormValidation = validations[key]
90+
const error = checkValidation<T>(data[key], validation)
91+
if (error) {
92+
newErrors[key] = error
93+
}
94+
})
95+
96+
if (Object.keys(newErrors).length) {
97+
setErrors(newErrors)
98+
return
99+
}
100+
}
101+
102+
setErrors({})
103+
onValid(data, e)
104+
}
105+
106+
/**
107+
* Manually triggers validation for specific form fields.
108+
*
109+
* @param name - The key(s) of the form field(s) to validate.
110+
* @returns The validation error(s), if any.
111+
*/
112+
const trigger = (name: keyof T | (keyof T)[]): (string | string[]) | (string | string[])[] => {
113+
const validations = options?.validations
114+
115+
if (Array.isArray(name)) {
116+
const newErrors: UseFormErrors<T> = {}
117+
118+
const _errors = name.map((key) => {
119+
if (validations) {
120+
const validation = validations[key]
121+
const error = checkValidation(data[key], validation)
122+
newErrors[key] = error
123+
124+
return error
125+
}
126+
127+
return null
128+
})
129+
130+
if (Object.keys(newErrors).length) {
131+
setErrors({ ...errors, ...newErrors })
132+
}
133+
134+
return _errors
135+
}
136+
137+
if (validations) {
138+
const validation = validations[name]
139+
const error = checkValidation(data[name], validation)
140+
141+
if (error) {
142+
setErrors({ ...errors, [name]: error })
143+
}
144+
145+
return error
146+
}
147+
148+
return null
149+
}
150+
151+
/**
152+
* Registers form input fields with onChange and onBlur handlers.
153+
*
154+
* @param name - The key of the form field to register.
155+
* @param sanitizeFn - An optional function to sanitize the input value.
156+
* @returns An object containing `onChange` and `onBlur` event handlers.
157+
*/
158+
const register = <S extends unknown>(name: keyof T, sanitizeFn?: (value: string) => S) => ({
159+
onChange: onChange(name, sanitizeFn),
160+
onBlur: onBlur(name),
161+
})
162+
163+
return {
164+
data,
165+
errors,
166+
register,
167+
handleSubmit,
168+
trigger,
169+
touchedFields,
170+
dirtyFields,
171+
isDirty: Object.values(dirtyFields).some((value) => value),
172+
}
173+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { FormEvent } from 'react'
2+
3+
type ValidationRequired =
4+
| boolean
5+
| {
6+
value: boolean
7+
message: string
8+
}
9+
10+
type ValidationPattern =
11+
| {
12+
value: RegExp
13+
message: string
14+
}
15+
| {
16+
value: RegExp
17+
message: string
18+
}[]
19+
20+
type ValidationCustom =
21+
| {
22+
isValid: (value: string) => boolean
23+
message: string
24+
}
25+
| {
26+
isValid: (value: string) => boolean
27+
message: string
28+
}[]
29+
30+
export interface UseFormValidation {
31+
required?: ValidationRequired
32+
pattern?: ValidationPattern
33+
custom?: ValidationCustom
34+
}
35+
36+
export type UseFormErrors<T> = Partial<Record<keyof T, string | string[]>>
37+
38+
export type DirtyFields<T> = Partial<Record<keyof T, boolean>>
39+
40+
export type TouchedFields<T> = Partial<Record<keyof T, boolean>>
41+
42+
export type UseFormValidations<T extends {}> = Partial<Record<keyof T, UseFormValidation>>
43+
44+
export type UseFormSubmitHandler<T extends {}> = (data: T, e?: FormEvent<HTMLFormElement>) => void
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { UseFormValidation } from './useForm.types'
2+
3+
/**
4+
* Validates a form field based on the provided validation rules.
5+
*
6+
* @template T - A record type representing form data.
7+
* @param value - The value of the form field to be validated.
8+
* @param validation - The validation rules for the form field.
9+
* @returns Returns error message(s) or null if valid.
10+
*/
11+
export const checkValidation = <T extends Record<keyof T, any> = {}>(
12+
value: T[keyof T],
13+
validation: UseFormValidation,
14+
): string | string[] | null => {
15+
if (
16+
(typeof validation?.required === 'object' ? validation.required.value : validation.required) &&
17+
(value === null || value === undefined || value === '')
18+
) {
19+
return typeof validation?.required === 'object' ? validation.required.message : 'This is a required field'
20+
}
21+
22+
const errors = []
23+
24+
const pattern = validation?.pattern
25+
if (Array.isArray(pattern)) {
26+
const error = pattern.reduce<string[]>((acc, p) => {
27+
if (!p.value.test(value)) {
28+
acc.push(p.message)
29+
}
30+
return acc
31+
}, [])
32+
33+
if (error.length) {
34+
errors.push(...error)
35+
}
36+
} else if (pattern?.value && !pattern.value.test(value)) {
37+
errors.push(pattern.message)
38+
}
39+
40+
const custom = validation?.custom
41+
if (Array.isArray(custom)) {
42+
const error = custom.reduce<string[]>((acc, c) => {
43+
if (!c.isValid(value)) {
44+
acc.push(c.message)
45+
}
46+
return acc
47+
}, [])
48+
49+
if (error.length) {
50+
errors.push(...error)
51+
}
52+
} else if (custom?.isValid && !custom.isValid(value)) {
53+
errors.push(custom.message)
54+
}
55+
56+
return errors.length ? errors : null
57+
}

0 commit comments

Comments
 (0)