From 6f6ef94ab5eefec6cfeec901a4f111ef77bd0e1d Mon Sep 17 00:00:00 2001 From: Ivan Filho Date: Tue, 14 May 2024 17:17:27 -0300 Subject: [PATCH] feat: add hasChildErrors to fieldErrors --- app/forms/hello/form.tsx | 22 ++++--- package.json | 2 +- src/helpers/parseZodError.ts | 15 +++-- src/types.ts | 7 ++- src/useForm.ts | 107 +++++++++++++++-------------------- src/useFormAction.ts | 4 +- 6 files changed, 78 insertions(+), 79 deletions(-) diff --git a/app/forms/hello/form.tsx b/app/forms/hello/form.tsx index 2a7b956..800f924 100644 --- a/app/forms/hello/form.tsx +++ b/app/forms/hello/form.tsx @@ -86,13 +86,6 @@ export const HelloForm = ({ {fieldErrors.attachment &&
{fieldErrors.attachment.first}
} - - {fieldErrors.terms && ( -
{fieldErrors.terms.first}
- )} {contacts.map((contact, index) => { const setSubfield = (subfield: 'name' | 'email', value: string) => @@ -146,6 +139,21 @@ export const HelloForm = ({ > Add contact + {fieldErrors.contacts && ( +
+ {fieldErrors.contacts.first ?? + (fieldErrors.contacts.hasChildErrors + ? 'There are errors in some of the contacts.' + : '')} +
+ )} + + {fieldErrors.terms && ( +
{fieldErrors.terms.first}
+ )} diff --git a/package.json b/package.json index f871f73..5f61abf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/helpers/parseZodError.ts b/src/helpers/parseZodError.ts index b8dd627..b9dd252 100644 --- a/src/helpers/parseZodError.ts +++ b/src/helpers/parseZodError.ts @@ -1,3 +1,4 @@ +import { shallowEqual } from '@/helpers/shallowEqual' import type { ZodError } from 'zod' import { FormFieldErrors, FormInput } from '..' @@ -7,15 +8,19 @@ export const parseZodError = ( 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[typeof key] } }, {}) diff --git a/src/types.ts b/src/types.ts index b1f114d..d925c2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,13 @@ -import { ZodError } from 'zod' +import { ZodIssue } from 'zod' export type FormInput = Record export type FormFieldErrors = { [field in keyof Input]?: { - first: string + first: string | undefined all: string[] - rawErrors: ZodError['errors'] + hasChildErrors: boolean + rawErrors: ZodIssue[] } } diff --git a/src/useForm.ts b/src/useForm.ts index 848db04..50b12c0 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -1,7 +1,7 @@ 'use client' -import { createFormData } from '@/helpers/serializer' import { shallowEqual } from '@/helpers/shallowEqual' +import { useFormAction } from '@/useFormAction' import { FormHTMLAttributes, HTMLAttributes, @@ -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' @@ -75,36 +72,50 @@ export const useForm = ({ onSuccess, onError }: UseFormParams): UseFormReturn => { + type ReturnObject = UseFormReturn + + 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 } | null >(null) - const [isPending, startTransition] = useTransition() const [isDirty, setIsDirty] = useState(false) - const [formState, formAction] = useFormState( - action ?? (() => null), - initialState ?? null - ) const [fieldErrors, setFieldErrors] = useState>({}) const values = useRef>(initialValues) - const [flushToggle, setFlushToggle] = useState(false) + const [_, setFlushToggle] = useState(false) const flush = useCallback(() => { setFlushToggle((toggle) => !toggle) }, []) - const reset = useCallback(() => { + const reset = useCallback(() => { values.current = initialValues setFieldErrors({}) setIsDirty(false) flush() }, [flush, initialValues]) - const getValues = useCallback(() => { + const getValues = useCallback(() => { return values.current }, []) - const setValues = useCallback( - (newValues: Partial, rerender: boolean = true) => { + const setValues = useCallback( + (newValues, rerender = true) => { // Set the dirty state if the values have changed if ( Object.keys(newValues).some( @@ -136,7 +147,7 @@ export const useForm = ({ [flush] ) - const validate = useCallback(() => { + const validate = useCallback(() => { // If there is no schema, skip validation if (!schema) return true @@ -153,8 +164,8 @@ export const useForm = ({ return true }, [setFieldErrors, schema]) - const validateField = useCallback( - (name: Field) => { + const validateField = useCallback( + (name) => { const value = values.current[name] // If there is no schema, skip validation @@ -191,16 +202,12 @@ export const useForm = ({ [setFieldErrors, schema] ) - const getField = useCallback((name: Field) => { + const getField = useCallback((name) => { return values.current[name] }, []) - const setField = useCallback( - ( - name: keyof Input, - value: Input[Field], - validate: boolean = true - ) => { + const setField = useCallback( + (name, value, validate = true) => { // Set the dirty state if the value has changed if (value !== values.current[name]) { setIsDirty(true) @@ -227,8 +234,8 @@ export const useForm = ({ [flush, validateField] ) - const bindField = useCallback( - (name: keyof Input) => { + const bindField = useCallback( + (name) => { if (inputRef.current === null) { inputRef.current = {} } @@ -248,9 +255,7 @@ export const useForm = ({ 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 & RefAttributes @@ -258,11 +263,8 @@ export const useForm = ({ [inputRef, setField, validateOnBlur, validateOnChange, initialValues] ) - const getFieldErrorByPath = useCallback( - ([fieldName, ...subpath]: [ - Field, - ...(string | number)[] - ]) => { + const getFieldErrorByPath = useCallback( + ([fieldName, ...subpath]) => { return fieldErrors[fieldName]?.rawErrors.find((e) => shallowEqual(e.path, [fieldName, ...subpath]) )?.message @@ -270,7 +272,7 @@ export const useForm = ({ [fieldErrors] ) - const submit = useCallback(async () => { + const submit = useCallback(async () => { // Reset field errors setFieldErrors({}) @@ -290,19 +292,11 @@ export const useForm = ({ 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(() => { return { onSubmit: async (event: SyntheticEvent) => { event.preventDefault() @@ -314,22 +308,11 @@ export const useForm = ({ } satisfies Pick, '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), + serverFieldErrors ?? fieldErrors ?? ({} as FormFieldErrors), isPending, isDirty, reset, diff --git a/src/useFormAction.ts b/src/useFormAction.ts index 9e776fc..7fbaab2 100644 --- a/src/useFormAction.ts +++ b/src/useFormAction.ts @@ -6,7 +6,7 @@ import { useFormState } from 'react-dom' import { FormAction, FormFieldErrors, FormInput, FormState } from './types' type UseFormActionParams = { - action?: FormAction | null + action: FormAction | null initialState?: FormState | null onSuccess?: (response: FormResponse) => void onError?: ( @@ -20,6 +20,7 @@ type UseFormActionReturn = { response: FormResponse | null fieldErrors: FormFieldErrors | null isPending: boolean + formAction: (payload: FormData) => void submit: (input: Input) => void } @@ -69,6 +70,7 @@ export const useFormAction = ({ response: formState?.response ?? null, fieldErrors: formState?.fieldErrors ?? null, isPending, + formAction, submit } }