Files
gridpilot.gg/apps/website/hooks/useEnhancedForm.ts
2026-01-14 23:46:04 +01:00

349 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, createErrorContext } from '@/lib/utils/errorUtils';
import { ApiError } from '@/lib/api/base/ApiError';
export interface FormField<T> {
value: T;
error?: string;
touched: boolean;
validating: boolean;
}
export interface FormState<T extends Record<string, any>> {
fields: { [K in keyof T]: FormField<T[K]> };
isValid: boolean;
isSubmitting: boolean;
submitError?: string;
submitCount: number;
}
export interface FormOptions<T extends Record<string, any>> {
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, any>> {
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, any>>(
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,
}));
// 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]);
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]);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
const checked = 'checked' in e.target ? e.target.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 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 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,
};
}