348 lines
9.5 KiB
TypeScript
348 lines
9.5 KiB
TypeScript
/**
|
|
* Enhanced Form Hook with Advanced Error Handling
|
|
*
|
|
* Provides comprehensive form state management, validation, and error handling
|
|
* with both user-friendly and developer-friendly error messages.
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, FormEvent, ChangeEvent, Dispatch, SetStateAction } from 'react';
|
|
import { parseApiError, formatValidationErrorsForForm, logErrorWithContext } from '@/lib/utils/errorUtils';
|
|
|
|
export interface FormField<T> {
|
|
value: T;
|
|
error?: string;
|
|
touched: boolean;
|
|
validating: boolean;
|
|
}
|
|
|
|
export interface FormState<T extends Record<string, unknown>> {
|
|
fields: { [K in keyof T]: FormField<T[K]> };
|
|
isValid: boolean;
|
|
isSubmitting: boolean;
|
|
submitError?: string;
|
|
submitCount: number;
|
|
}
|
|
|
|
export interface FormOptions<T extends Record<string, unknown>> {
|
|
initialValues: T;
|
|
validate?: (values: T) => Record<string, string> | Promise<Record<string, string>>;
|
|
onSubmit: (values: T) => Promise<void>;
|
|
onError?: (error: unknown, values: T) => void;
|
|
onSuccess?: (values: T) => void;
|
|
component?: string;
|
|
}
|
|
|
|
export interface UseEnhancedFormReturn<T extends Record<string, unknown>> {
|
|
formState: FormState<T>;
|
|
setFormState: Dispatch<SetStateAction<FormState<T>>>;
|
|
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
|
|
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
|
setFieldError: <K extends keyof T>(field: K, error: string) => void;
|
|
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>;
|
|
reset: () => void;
|
|
setFormError: (error: string) => void;
|
|
clearFieldError: <K extends keyof T>(field: K) => void;
|
|
validateField: <K extends keyof T>(field: K) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Enhanced form hook with comprehensive error handling
|
|
*/
|
|
export function useEnhancedForm<T extends Record<string, unknown>>(
|
|
options: FormOptions<T>
|
|
): UseEnhancedFormReturn<T> {
|
|
const [formState, setFormState] = useState<FormState<T>>(() => ({
|
|
fields: Object.keys(options.initialValues).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: {
|
|
value: options.initialValues[key as keyof T],
|
|
error: undefined,
|
|
touched: false,
|
|
validating: false,
|
|
}
|
|
}), {} as { [K in keyof T]: FormField<T[K]> }),
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitError: undefined,
|
|
submitCount: 0,
|
|
}));
|
|
|
|
const getValues = useCallback((): T => {
|
|
return Object.keys(formState.fields).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: formState.fields[key as keyof T].value,
|
|
}), {} as T);
|
|
}, [formState.fields]);
|
|
|
|
// Validate form on change
|
|
useEffect(() => {
|
|
if (options.validate && formState.submitCount > 0) {
|
|
const validateAsync = async () => {
|
|
try {
|
|
const errors = await options.validate!(getValues());
|
|
setFormState(prev => ({
|
|
...prev,
|
|
isValid: Object.keys(errors).length === 0,
|
|
fields: Object.keys(prev.fields).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: {
|
|
...prev.fields[key as keyof T],
|
|
error: errors[key],
|
|
}
|
|
}), {} as { [K in keyof T]: FormField<T[K]> }),
|
|
}));
|
|
} catch (error) {
|
|
console.error('Validation error:', error);
|
|
}
|
|
};
|
|
validateAsync();
|
|
}
|
|
}, [formState.fields, formState.submitCount, options.validate, getValues]);
|
|
|
|
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
|
const { name, value, type } = e.target;
|
|
const checked = 'checked' in e.target ? (e.target as HTMLInputElement).checked : false;
|
|
const fieldValue = type === 'checkbox' ? checked : value;
|
|
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[name]: {
|
|
...prev.fields[name as keyof T],
|
|
value: fieldValue as T[keyof T],
|
|
touched: true,
|
|
error: undefined, // Clear error on change
|
|
},
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const setFieldValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[field]: {
|
|
...prev.fields[field],
|
|
value,
|
|
touched: true,
|
|
error: undefined,
|
|
},
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const setFieldError = useCallback(<K extends keyof T>(field: K, error: string) => {
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[field]: {
|
|
...prev.fields[field],
|
|
error,
|
|
touched: true,
|
|
},
|
|
},
|
|
isValid: false,
|
|
}));
|
|
}, []);
|
|
|
|
const clearFieldError = useCallback(<K extends keyof T>(field: K) => {
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[field]: {
|
|
...prev.fields[field],
|
|
error: undefined,
|
|
},
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const setFormError = useCallback((error: string) => {
|
|
setFormState(prev => ({
|
|
...prev,
|
|
submitError: error,
|
|
}));
|
|
}, []);
|
|
|
|
const validateField = useCallback(async <K extends keyof T>(field: K) => {
|
|
if (!options.validate) return;
|
|
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[field]: {
|
|
...prev.fields[field],
|
|
validating: true,
|
|
},
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const values = getValues();
|
|
const errors = await options.validate(values);
|
|
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[field]: {
|
|
...prev.fields[field],
|
|
error: errors[field as string],
|
|
validating: false,
|
|
},
|
|
},
|
|
}));
|
|
} catch (error) {
|
|
setFormState(prev => ({
|
|
...prev,
|
|
fields: {
|
|
...prev.fields,
|
|
[field]: {
|
|
...prev.fields[field],
|
|
validating: false,
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
}, [options.validate, getValues]);
|
|
|
|
const reset = useCallback(() => {
|
|
setFormState({
|
|
fields: Object.keys(options.initialValues).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: {
|
|
value: options.initialValues[key as keyof T],
|
|
error: undefined,
|
|
touched: false,
|
|
validating: false,
|
|
}
|
|
}), {} as { [K in keyof T]: FormField<T[K]> }),
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitError: undefined,
|
|
submitCount: 0,
|
|
});
|
|
}, [options.initialValues]);
|
|
|
|
const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
const values = getValues();
|
|
|
|
// Increment submit count to trigger validation
|
|
setFormState(prev => ({
|
|
...prev,
|
|
submitCount: prev.submitCount + 1,
|
|
isSubmitting: true,
|
|
submitError: undefined,
|
|
}));
|
|
|
|
// Run validation if provided
|
|
if (options.validate) {
|
|
try {
|
|
const errors = await options.validate(values);
|
|
const hasErrors = Object.keys(errors).length > 0;
|
|
|
|
if (hasErrors) {
|
|
setFormState(prev => ({
|
|
...prev,
|
|
isSubmitting: false,
|
|
isValid: false,
|
|
fields: Object.keys(prev.fields).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: {
|
|
...prev.fields[key as keyof T],
|
|
error: errors[key],
|
|
touched: true,
|
|
}
|
|
}), {} as { [K in keyof T]: FormField<T[K]> }),
|
|
}));
|
|
return;
|
|
}
|
|
} catch (validationError) {
|
|
logErrorWithContext(validationError, {
|
|
timestamp: new globalThis.Date().toISOString(),
|
|
component: options.component || 'useEnhancedForm',
|
|
action: 'validate',
|
|
formData: values,
|
|
});
|
|
|
|
setFormState(prev => ({
|
|
...prev,
|
|
isSubmitting: false,
|
|
submitError: 'Validation failed. Please check your input.',
|
|
}));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Submit the form
|
|
try {
|
|
await options.onSubmit(values);
|
|
|
|
setFormState(prev => ({
|
|
...prev,
|
|
isSubmitting: false,
|
|
submitError: undefined,
|
|
}));
|
|
|
|
options.onSuccess?.(values);
|
|
} catch (error) {
|
|
const parsed = parseApiError(error);
|
|
|
|
// Log for developers
|
|
logErrorWithContext(error, {
|
|
timestamp: new globalThis.Date().toISOString(),
|
|
component: options.component || 'useEnhancedForm',
|
|
action: 'submit',
|
|
formData: values,
|
|
});
|
|
|
|
// Handle validation errors from API
|
|
if (parsed.isValidationError && parsed.validationErrors.length > 0) {
|
|
const fieldErrors = formatValidationErrorsForForm(parsed.validationErrors);
|
|
|
|
setFormState(prev => ({
|
|
...prev,
|
|
isSubmitting: false,
|
|
isValid: false,
|
|
fields: Object.keys(prev.fields).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: {
|
|
...prev.fields[key as keyof T],
|
|
error: fieldErrors[key],
|
|
touched: true,
|
|
}
|
|
}), {} as { [K in keyof T]: FormField<T[K]> }),
|
|
}));
|
|
} else {
|
|
// General submit error
|
|
setFormState(prev => ({
|
|
...prev,
|
|
isSubmitting: false,
|
|
submitError: parsed.userMessage,
|
|
}));
|
|
}
|
|
|
|
options.onError?.(error, values);
|
|
}
|
|
}, [getValues, options]);
|
|
|
|
return {
|
|
formState,
|
|
setFormState,
|
|
handleChange,
|
|
setFieldValue,
|
|
setFieldError,
|
|
handleSubmit,
|
|
reset,
|
|
setFormError,
|
|
clearFieldError,
|
|
validateField,
|
|
};
|
|
} |