UNPKG

@matthew.ngo/reform

Version:

A flexible and powerful React form management library with advanced validation, state observation, and multi-group support

283 lines (252 loc) 8.61 kB
/** * Hook for implementing conditional field rendering and validation in Reform forms. * Provides utilities to show/hide and validate fields based on dynamic conditions. */ import { useMemo, useRef, useEffect } from 'react'; import { UseFormReturn, useWatch, Path } from 'react-hook-form'; import { ConditionalFieldsManager, GroupConditionalRenderProps, ConditionalRenderProps, } from './types'; import { useMemoizedCallback } from '../../common/useMemoizedCallback'; import { FormGroup } from '../../core/form/form-groups'; /** * Props for the useConditionalFields hook * * @template T - The type of form data */ interface UseConditionalFieldsProps<T> { /** React Hook Form methods */ methods: UseFormReturn<{ groups: FormGroup<T>[] }>; } /** * Hook that provides utilities for conditional field rendering and validation * * @template T - The type of form data * @param props - Hook configuration * @returns Object with conditional field utilities */ export const useConditionalFields = <T extends Record<string, any>>({ methods, }: UseConditionalFieldsProps<T>): ConditionalFieldsManager<T> => { // Store methods reference to avoid unnecessary re-renders const methodsRef = useRef(methods); useEffect(() => { methodsRef.current = methods; }, [methods]); // Watch all form values for conditional logic const formValues = useWatch({ control: methods.control, }); // Memoize form values to prevent unnecessary re-renders const memoizedFormValues = useMemo(() => formValues, [ // Use JSON.stringify for complex objects to compare by value JSON.stringify( formValues?.groups?.map(group => ({ id: group.id, data: group.data, })) ), ]); const when = useMemoizedCallback( ( groupIndex: number, condition: (groupData: T) => boolean, options?: { clearWhenHidden?: boolean; validateWhenHidden?: boolean; } ): GroupConditionalRenderProps<T> => { const { clearWhenHidden = true, validateWhenHidden = false } = options || {}; // Get the current group data const groupData = memoizedFormValues?.groups?.[groupIndex]?.data as | T | undefined; // If group doesn't exist, return default values if (!groupData) { return { shouldShow: false, shouldValidate: validateWhenHidden, render: () => null, registerIf: <K extends keyof T>(fieldName: K) => ({ name: `groups.${groupIndex}.data.${String(fieldName)}`, onChange: () => {}, onBlur: () => {}, ref: () => {}, }), validateIf: () => undefined, }; } // Evaluate the condition const shouldShow = condition(groupData); return { shouldShow, shouldValidate: shouldShow || validateWhenHidden, /** * Helper for JSX rendering that conditionally renders a component * * @param component - The React component to conditionally render * @returns The component if condition is met, otherwise null */ render: (component: React.ReactNode) => { return shouldShow ? component : null; }, /** * Helper for field registration that conditionally registers a field * * @param fieldName - The name of the field to register * @param options - Registration options passed to React Hook Form * @returns Either a real or dummy registration object */ registerIf: <K extends keyof T>(fieldName: K, options?: any) => { if (shouldShow || validateWhenHidden) { return methodsRef.current.register( `groups.${groupIndex}.data.${String(fieldName)}` as Path<{ groups: FormGroup<T>[]; }>, options ); } // If field is hidden and we should clear its value if (clearWhenHidden) { const fieldPath = `groups.${groupIndex}.data.${String( fieldName )}` as Path<{ groups: FormGroup<T>[] }>; // Use null instead of undefined for clearer type compatibility // or use the initial/default value for this field type if available methodsRef.current.setValue(fieldPath, null as any, { shouldValidate: false, }); } // Return a dummy register object when field is hidden return { name: `groups.${groupIndex}.data.${String(fieldName)}`, onChange: () => {}, onBlur: () => {}, ref: () => {}, }; }, /** * Helper for field validation that conditionally validates a field * * @param fieldName - The name of the field to validate * @param validationFn - Function that validates the field value * @returns Validation error message or undefined if valid/skipped */ validateIf: <K extends keyof T>( fieldName: K, validationFn: (value: T[K]) => string | undefined ) => { if (!shouldShow && !validateWhenHidden) { return undefined; } const value = groupData[fieldName]; return validationFn(value); }, }; }, [memoizedFormValues] ); const whenAny = useMemoizedCallback( ( condition: (allGroups: FormGroup<T>[]) => boolean, options?: { clearWhenHidden?: boolean; validateWhenHidden?: boolean; } ): ConditionalRenderProps => { const { clearWhenHidden = true, validateWhenHidden = false } = options || {}; // Get all groups const groups = memoizedFormValues?.groups as FormGroup<T>[] | undefined; // If no groups exist, return default values if (!groups || !Array.isArray(groups)) { return { shouldShow: false, shouldValidate: validateWhenHidden, render: () => null, }; } // Evaluate the condition const shouldShow = condition(groups); return { shouldShow, shouldValidate: shouldShow || validateWhenHidden, /** * Helper for JSX rendering that conditionally renders a component * * @param component - The React component to conditionally render * @returns The component if condition is met, otherwise null */ render: (component: React.ReactNode) => { return shouldShow ? component : null; }, }; }, [memoizedFormValues] ); const createDependencyTracker = useMemoizedCallback( <K extends keyof T>( groupIndex: number, dependencies: K[], effect: (values: Pick<T, K>) => void ): void => { // Extract only the dependent values const groupData = memoizedFormValues?.groups?.[groupIndex]?.data as | T | undefined; if (groupData) { const dependentValues = dependencies.reduce((acc, key) => { acc[key] = groupData[key]; return acc; }, {} as Pick<T, K>); // Run the effect with the dependent values effect(dependentValues); } }, [memoizedFormValues] ); const whenCross = useMemoizedCallback( ( condition: (formData: { groups: FormGroup<T>[] }) => boolean ): ConditionalRenderProps => { // If no form values exist, return default values if (!memoizedFormValues || !memoizedFormValues.groups) { return { shouldShow: false, shouldValidate: false, render: () => null, }; } // Create a properly typed version of the form data const typedFormData = { groups: memoizedFormValues.groups.map(group => ({ id: group.id || '', // Ensure id is not undefined data: group.data || ({} as T), // Ensure data is not undefined })) as FormGroup<T>[], }; // Evaluate the condition with all form data const shouldShow = condition(typedFormData); return { shouldShow, shouldValidate: shouldShow, render: (component: React.ReactNode) => { return shouldShow ? component : null; }, }; }, [memoizedFormValues] ); // Memoize the return object to prevent unnecessary re-renders return useMemo( () => ({ when, whenAny, whenCross, createDependencyTracker, }), [when, whenAny, whenCross, createDependencyTracker] ); };