Skip to content

Commit

Permalink
feat: add hasChildErrors to fieldErrors
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanfilhoz committed May 14, 2024
1 parent 51f634f commit 6f6ef94
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 79 deletions.
22 changes: 15 additions & 7 deletions app/forms/hello/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,6 @@ export const HelloForm = ({
<label htmlFor='attachment'>Attachment</label>
<input type='file' {...bindField('attachment')} />
{fieldErrors.attachment && <pre>{fieldErrors.attachment.first}</pre>}
<label htmlFor='terms'>
<input {...bindField('terms')} type='checkbox' autoComplete='off' /> I
accept the terms
</label>
{fieldErrors.terms && (
<div className='text-sm text-red-500'>{fieldErrors.terms.first}</div>
)}
<label htmlFor='contacts'>Contacts</label>
{contacts.map((contact, index) => {
const setSubfield = (subfield: 'name' | 'email', value: string) =>
Expand Down Expand Up @@ -146,6 +139,21 @@ export const HelloForm = ({
>
Add contact
</button>
{fieldErrors.contacts && (
<div className='text-sm text-red-500'>
{fieldErrors.contacts.first ??
(fieldErrors.contacts.hasChildErrors
? 'There are errors in some of the contacts.'
: '')}
</div>
)}
<label htmlFor='terms'>
<input {...bindField('terms')} type='checkbox' autoComplete='off' /> I
accept the terms
</label>
{fieldErrors.terms && (
<div className='text-sm text-red-500'>{fieldErrors.terms.first}</div>
)}
<button type='submit' disabled={isPending}>
Submit
</button>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "safe-form",
"version": "0.9.0",
"version": "0.10.0",
"description": "⚡️ End-to-end type-safety from client to server.",
"main": "dist/safe-form.es.js",
"types": "dist/index.d.ts",
Expand Down
15 changes: 10 additions & 5 deletions src/helpers/parseZodError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { shallowEqual } from '@/helpers/shallowEqual'
import type { ZodError } from 'zod'
import { FormFieldErrors, FormInput } from '..'

Expand All @@ -7,15 +8,19 @@ export const parseZodError = <Input extends FormInput>(
const { fieldErrors } = error.flatten()

return Object.keys(fieldErrors).reduce((acc, key) => {
const fieldError = fieldErrors[key as keyof typeof fieldErrors] as string[]
const first = fieldError?.[0]
const rawErrors = error.errors.filter((e) => e.path[0] === key)

const all = rawErrors
.filter((e) => shallowEqual(e.path, [key]))
.map((issue) => issue.message)

return {
...acc,
[key]: {
first,
all: fieldError,
rawErrors: error.errors
first: all[0],
all,
hasChildErrors: rawErrors.length > all.length,
rawErrors
} satisfies FormFieldErrors<Input>[typeof key]
}
}, {})
Expand Down
7 changes: 4 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { ZodError } from 'zod'
import { ZodIssue } from 'zod'

export type FormInput = Record<string, any>

export type FormFieldErrors<Input extends FormInput> = {
[field in keyof Input]?: {
first: string
first: string | undefined
all: string[]
rawErrors: ZodError<Input>['errors']
hasChildErrors: boolean
rawErrors: ZodIssue[]
}
}

Expand Down
107 changes: 45 additions & 62 deletions src/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { createFormData } from '@/helpers/serializer'
import { shallowEqual } from '@/helpers/shallowEqual'
import { useFormAction } from '@/useFormAction'
import {
FormHTMLAttributes,
HTMLAttributes,
Expand All @@ -11,12 +11,9 @@ import {
SyntheticEvent,
createRef,
useCallback,
useEffect,
useRef,
useState,
useTransition
useState
} from 'react'
import { useFormState } from 'react-dom'
import type { Schema } from 'zod'
import { parseValueFromInput } from './helpers/parseValueFromInput'
import { parseZodError } from './helpers/parseZodError'
Expand Down Expand Up @@ -75,36 +72,50 @@ export const useForm = <Input extends FormInput, FormResponse>({
onSuccess,
onError
}: UseFormParams<Input, FormResponse>): UseFormReturn<Input, FormResponse> => {
type ReturnObject = UseFormReturn<Input, FormResponse>

const {
isPending,
formAction,
submit: serverSubmit,
error: serverError,
response: serverResponse,
fieldErrors: serverFieldErrors
} = useFormAction({
action: action ?? null,
initialState,
onSuccess: (response) => {
setIsDirty(false)
onSuccess?.(response)
},
onError
})

const inputRef = useRef<
{ [field in keyof Input]?: RefObject<BindableField> } | null
>(null)
const [isPending, startTransition] = useTransition()
const [isDirty, setIsDirty] = useState(false)
const [formState, formAction] = useFormState(
action ?? (() => null),
initialState ?? null
)
const [fieldErrors, setFieldErrors] = useState<FormFieldErrors<Input>>({})
const values = useRef<Partial<Input>>(initialValues)
const [flushToggle, setFlushToggle] = useState(false)
const [_, setFlushToggle] = useState(false)

const flush = useCallback(() => {
setFlushToggle((toggle) => !toggle)
}, [])

const reset = useCallback(() => {
const reset = useCallback<ReturnObject['reset']>(() => {
values.current = initialValues
setFieldErrors({})
setIsDirty(false)
flush()
}, [flush, initialValues])

const getValues = useCallback(() => {
const getValues = useCallback<ReturnObject['getValues']>(() => {
return values.current
}, [])

const setValues = useCallback(
(newValues: Partial<Input>, rerender: boolean = true) => {
const setValues = useCallback<ReturnObject['setValues']>(
(newValues, rerender = true) => {
// Set the dirty state if the values have changed
if (
Object.keys(newValues).some(
Expand Down Expand Up @@ -136,7 +147,7 @@ export const useForm = <Input extends FormInput, FormResponse>({
[flush]
)

const validate = useCallback(() => {
const validate = useCallback<ReturnObject['validate']>(() => {
// If there is no schema, skip validation
if (!schema) return true

Expand All @@ -153,8 +164,8 @@ export const useForm = <Input extends FormInput, FormResponse>({
return true
}, [setFieldErrors, schema])

const validateField = useCallback(
<Field extends keyof Input>(name: Field) => {
const validateField = useCallback<ReturnObject['validateField']>(
(name) => {
const value = values.current[name]

// If there is no schema, skip validation
Expand Down Expand Up @@ -191,16 +202,12 @@ export const useForm = <Input extends FormInput, FormResponse>({
[setFieldErrors, schema]
)

const getField = useCallback(<Field extends keyof Input>(name: Field) => {
const getField = useCallback<ReturnObject['getField']>((name) => {
return values.current[name]
}, [])

const setField = useCallback(
<Field extends keyof Input>(
name: keyof Input,
value: Input[Field],
validate: boolean = true
) => {
const setField = useCallback<ReturnObject['setField']>(
(name, value, validate = true) => {
// Set the dirty state if the value has changed
if (value !== values.current[name]) {
setIsDirty(true)
Expand All @@ -227,8 +234,8 @@ export const useForm = <Input extends FormInput, FormResponse>({
[flush, validateField]
)

const bindField = useCallback(
(name: keyof Input) => {
const bindField = useCallback<ReturnObject['bindField']>(
(name) => {
if (inputRef.current === null) {
inputRef.current = {}
}
Expand All @@ -248,29 +255,24 @@ export const useForm = <Input extends FormInput, FormResponse>({
ref: inputRef.current[name],
name: name.toString(),
defaultValue: initialValues?.[name] ?? '',
onBlur: () => {
mutate(name, validateOnBlur)
},
onBlur: () => mutate(name, validateOnBlur),
onChange: validateOnChange ? () => mutate(name) : undefined
} satisfies InputHTMLAttributes<HTMLInputElement> &
RefAttributes<BindableField>
},
[inputRef, setField, validateOnBlur, validateOnChange, initialValues]
)

const getFieldErrorByPath = useCallback(
<Field extends keyof Input>([fieldName, ...subpath]: [
Field,
...(string | number)[]
]) => {
const getFieldErrorByPath = useCallback<ReturnObject['getFieldErrorByPath']>(
([fieldName, ...subpath]) => {
return fieldErrors[fieldName]?.rawErrors.find((e) =>
shallowEqual(e.path, [fieldName, ...subpath])
)?.message
},
[fieldErrors]
)

const submit = useCallback(async () => {
const submit = useCallback<ReturnObject['submit']>(async () => {
// Reset field errors
setFieldErrors({})

Expand All @@ -290,19 +292,11 @@ export const useForm = <Input extends FormInput, FormResponse>({
if (!shouldSubmit) return
}

// If there is no action, skip the submission
if (!action) return
// Submit the server action
serverSubmit(input)
}, [schema, validate, values, onSubmit, serverSubmit])

// Create a FormData object from the values
const formData = createFormData(input)

// Call the server action
startTransition(async () => {
await formAction(formData)
})
}, [action, formAction, schema, validate, values, onSubmit])

const connect = useCallback(() => {
const connect = useCallback<ReturnObject['connect']>(() => {
return {
onSubmit: async (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault()
Expand All @@ -314,22 +308,11 @@ export const useForm = <Input extends FormInput, FormResponse>({
} satisfies Pick<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'action'>
}, [submit, formAction])

useEffect(() => {
if (formState?.error || formState?.fieldErrors) {
onError?.(formState?.error ?? null, formState?.fieldErrors ?? null)
}
if (formState?.response) {
setIsDirty(false)
onSuccess?.(formState.response)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formState])

return {
error: formState?.error ?? null,
response: formState?.response ?? null,
error: serverError,
response: serverResponse,
fieldErrors:
formState?.fieldErrors ?? fieldErrors ?? ({} as FormFieldErrors<Input>),
serverFieldErrors ?? fieldErrors ?? ({} as FormFieldErrors<Input>),
isPending,
isDirty,
reset,
Expand Down
4 changes: 3 additions & 1 deletion src/useFormAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useFormState } from 'react-dom'
import { FormAction, FormFieldErrors, FormInput, FormState } from './types'

type UseFormActionParams<Input extends FormInput, FormResponse> = {
action?: FormAction<Input, FormResponse> | null
action: FormAction<Input, FormResponse> | null
initialState?: FormState<Input, FormResponse> | null
onSuccess?: (response: FormResponse) => void
onError?: (
Expand All @@ -20,6 +20,7 @@ type UseFormActionReturn<Input extends FormInput, FormResponse> = {
response: FormResponse | null
fieldErrors: FormFieldErrors<Input> | null
isPending: boolean
formAction: (payload: FormData) => void
submit: (input: Input) => void
}

Expand Down Expand Up @@ -69,6 +70,7 @@ export const useFormAction = <Input extends FormInput, FormResponse>({
response: formState?.response ?? null,
fieldErrors: formState?.fieldErrors ?? null,
isPending,
formAction,
submit
}
}

0 comments on commit 6f6ef94

Please sign in to comment.