refactor(forms): extract reusable form utilities and components
- Add getFirstValidationError utility for nested FieldErrors extraction - Add mergeWithDefaults utilities (deepMergeWithDefaults, type guards) - Add useValidationErrorHandler hook for toast + tab navigation - Add FormSelect component with Controller integration - Add FormTextarea component with register integration - Refactor AgentTypeForm to use new utilities - Remove verbose debug logging (now handled by hook) - Add comprehensive tests (53 new tests, 100 total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
118
frontend/src/lib/forms/hooks/useValidationErrorHandler.ts
Normal file
118
frontend/src/lib/forms/hooks/useValidationErrorHandler.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Validation Error Handler Hook
|
||||
*
|
||||
* Handles client-side Zod/react-hook-form validation errors with:
|
||||
* - Toast notifications
|
||||
* - Optional tab navigation
|
||||
* - Debug logging
|
||||
*
|
||||
* @module lib/forms/hooks/useValidationErrorHandler
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { FieldErrors, FieldValues } from 'react-hook-form';
|
||||
import { getFirstValidationError } from '../utils/getFirstValidationError';
|
||||
|
||||
export interface TabFieldMapping {
|
||||
/** Map of field names to tab values */
|
||||
[fieldName: string]: string;
|
||||
}
|
||||
|
||||
export interface UseValidationErrorHandlerOptions {
|
||||
/**
|
||||
* Map of field names (top-level) to tab values.
|
||||
* When an error occurs, navigates to the tab containing the field.
|
||||
*/
|
||||
tabMapping?: TabFieldMapping;
|
||||
|
||||
/**
|
||||
* Callback to set the active tab.
|
||||
* Required if tabMapping is provided.
|
||||
*/
|
||||
setActiveTab?: (tab: string) => void;
|
||||
|
||||
/**
|
||||
* Enable debug logging to console.
|
||||
* @default false in production, true in development
|
||||
*/
|
||||
debug?: boolean;
|
||||
|
||||
/**
|
||||
* Toast title for validation errors.
|
||||
* @default 'Please fix form errors'
|
||||
*/
|
||||
toastTitle?: string;
|
||||
}
|
||||
|
||||
export interface UseValidationErrorHandlerReturn<T extends FieldValues> {
|
||||
/**
|
||||
* Handler function to pass to react-hook-form's handleSubmit second argument.
|
||||
* Shows toast, navigates to tab, and logs errors.
|
||||
*/
|
||||
onValidationError: (errors: FieldErrors<T>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling client-side validation errors
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [activeTab, setActiveTab] = useState('basic');
|
||||
*
|
||||
* const { onValidationError } = useValidationErrorHandler({
|
||||
* tabMapping: {
|
||||
* name: 'basic',
|
||||
* slug: 'basic',
|
||||
* primary_model: 'model',
|
||||
* model_params: 'model',
|
||||
* },
|
||||
* setActiveTab,
|
||||
* });
|
||||
*
|
||||
* // In form:
|
||||
* <form onSubmit={handleSubmit(onSuccess, onValidationError)}>
|
||||
* ```
|
||||
*/
|
||||
export function useValidationErrorHandler<T extends FieldValues>(
|
||||
options: UseValidationErrorHandlerOptions = {}
|
||||
): UseValidationErrorHandlerReturn<T> {
|
||||
const {
|
||||
tabMapping,
|
||||
setActiveTab,
|
||||
debug = process.env.NODE_ENV === 'development',
|
||||
toastTitle = 'Please fix form errors',
|
||||
} = options;
|
||||
|
||||
const onValidationError = useCallback(
|
||||
(errors: FieldErrors<T>) => {
|
||||
// Log errors in debug mode
|
||||
if (debug) {
|
||||
console.error('[Form Validation] Errors:', errors);
|
||||
}
|
||||
|
||||
// Get first error for toast
|
||||
const firstError = getFirstValidationError(errors);
|
||||
if (!firstError) return;
|
||||
|
||||
// Show toast
|
||||
toast.error(toastTitle, {
|
||||
description: `${firstError.field}: ${firstError.message}`,
|
||||
});
|
||||
|
||||
// Navigate to tab if mapping provided
|
||||
if (tabMapping && setActiveTab) {
|
||||
const topLevelField = firstError.field.split('.')[0];
|
||||
const targetTab = tabMapping[topLevelField];
|
||||
if (targetTab) {
|
||||
setActiveTab(targetTab);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabMapping, setActiveTab, debug, toastTitle]
|
||||
);
|
||||
|
||||
return { onValidationError };
|
||||
}
|
||||
30
frontend/src/lib/forms/index.ts
Normal file
30
frontend/src/lib/forms/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Form Utilities and Hooks
|
||||
*
|
||||
* Centralized exports for form-related utilities.
|
||||
*
|
||||
* @module lib/forms
|
||||
*/
|
||||
|
||||
// Utils
|
||||
export { getFirstValidationError, getAllValidationErrors } from './utils/getFirstValidationError';
|
||||
export type { ValidationError } from './utils/getFirstValidationError';
|
||||
|
||||
export {
|
||||
safeValue,
|
||||
isNumber,
|
||||
isString,
|
||||
isBoolean,
|
||||
isArray,
|
||||
isObject,
|
||||
deepMergeWithDefaults,
|
||||
createFormInitializer,
|
||||
} from './utils/mergeWithDefaults';
|
||||
|
||||
// Hooks
|
||||
export { useValidationErrorHandler } from './hooks/useValidationErrorHandler';
|
||||
export type {
|
||||
TabFieldMapping,
|
||||
UseValidationErrorHandlerOptions,
|
||||
UseValidationErrorHandlerReturn,
|
||||
} from './hooks/useValidationErrorHandler';
|
||||
84
frontend/src/lib/forms/utils/getFirstValidationError.ts
Normal file
84
frontend/src/lib/forms/utils/getFirstValidationError.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Get First Validation Error
|
||||
*
|
||||
* Extracts the first error from react-hook-form FieldErrors,
|
||||
* including support for nested errors (e.g., model_params.temperature).
|
||||
*
|
||||
* @module lib/forms/utils/getFirstValidationError
|
||||
*/
|
||||
|
||||
import type { FieldErrors, FieldValues } from 'react-hook-form';
|
||||
|
||||
export interface ValidationError {
|
||||
/** Field path (e.g., 'name' or 'model_params.temperature') */
|
||||
field: string;
|
||||
/** Error message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract the first error from FieldErrors
|
||||
*
|
||||
* @param errors - FieldErrors object from react-hook-form
|
||||
* @param prefix - Current field path prefix for nested errors
|
||||
* @returns First validation error found, or null if no errors
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const errors = { model_params: { temperature: { message: 'Required' } } };
|
||||
* const error = getFirstValidationError(errors);
|
||||
* // { field: 'model_params.temperature', message: 'Required' }
|
||||
* ```
|
||||
*/
|
||||
export function getFirstValidationError<T extends FieldValues>(
|
||||
errors: FieldErrors<T>,
|
||||
prefix = ''
|
||||
): ValidationError | null {
|
||||
for (const key of Object.keys(errors)) {
|
||||
const error = errors[key as keyof typeof errors];
|
||||
if (!error || typeof error !== 'object') continue;
|
||||
|
||||
const fieldPath = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
// Check if this is a direct error with a message
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
return { field: fieldPath, message: error.message };
|
||||
}
|
||||
|
||||
// Check if this is a nested object (e.g., model_params.temperature)
|
||||
const nestedError = getFirstValidationError(error as FieldErrors<FieldValues>, fieldPath);
|
||||
if (nestedError) return nestedError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all validation errors as a flat array
|
||||
*
|
||||
* @param errors - FieldErrors object from react-hook-form
|
||||
* @param prefix - Current field path prefix for nested errors
|
||||
* @returns Array of all validation errors
|
||||
*/
|
||||
export function getAllValidationErrors<T extends FieldValues>(
|
||||
errors: FieldErrors<T>,
|
||||
prefix = ''
|
||||
): ValidationError[] {
|
||||
const result: ValidationError[] = [];
|
||||
|
||||
for (const key of Object.keys(errors)) {
|
||||
const error = errors[key as keyof typeof errors];
|
||||
if (!error || typeof error !== 'object') continue;
|
||||
|
||||
const fieldPath = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
result.push({ field: fieldPath, message: error.message });
|
||||
} else {
|
||||
// Nested object without message, recurse
|
||||
result.push(...getAllValidationErrors(error as FieldErrors<FieldValues>, fieldPath));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
165
frontend/src/lib/forms/utils/mergeWithDefaults.ts
Normal file
165
frontend/src/lib/forms/utils/mergeWithDefaults.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Merge With Defaults
|
||||
*
|
||||
* Utilities for safely merging API data with form defaults.
|
||||
* Handles missing fields, type mismatches, and nested objects.
|
||||
*
|
||||
* @module lib/forms/utils/mergeWithDefaults
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely get a value with type checking and default fallback
|
||||
*
|
||||
* @param value - Value to check
|
||||
* @param defaultValue - Default to use if value is invalid
|
||||
* @param typeCheck - Type checking function
|
||||
* @returns Valid value or default
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const temp = safeValue(apiData.temperature, 0.7, (v) => typeof v === 'number');
|
||||
* ```
|
||||
*/
|
||||
export function safeValue<T>(
|
||||
value: unknown,
|
||||
defaultValue: T,
|
||||
typeCheck: (v: unknown) => v is T
|
||||
): T {
|
||||
return typeCheck(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for numbers
|
||||
*/
|
||||
export function isNumber(v: unknown): v is number {
|
||||
return typeof v === 'number' && !Number.isNaN(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for strings
|
||||
*/
|
||||
export function isString(v: unknown): v is string {
|
||||
return typeof v === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for booleans
|
||||
*/
|
||||
export function isBoolean(v: unknown): v is boolean {
|
||||
return typeof v === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for arrays
|
||||
*/
|
||||
export function isArray<T>(v: unknown, itemCheck?: (item: unknown) => item is T): v is T[] {
|
||||
if (!Array.isArray(v)) return false;
|
||||
if (itemCheck) return v.every(itemCheck);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for objects (non-null, non-array)
|
||||
*/
|
||||
export function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects, with source values taking precedence
|
||||
* Only merges values that pass type checking against defaults
|
||||
*
|
||||
* @param defaults - Default values (used as type template)
|
||||
* @param source - Source values to merge (from API)
|
||||
* @returns Merged object with all fields from defaults
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const defaults = { temperature: 0.7, max_tokens: 8192, top_p: 0.95 };
|
||||
* const apiData = { temperature: 0.5 }; // missing max_tokens and top_p
|
||||
* const merged = deepMergeWithDefaults(defaults, apiData);
|
||||
* // { temperature: 0.5, max_tokens: 8192, top_p: 0.95 }
|
||||
* ```
|
||||
*/
|
||||
export function deepMergeWithDefaults<T extends Record<string, unknown>>(
|
||||
defaults: T,
|
||||
source: Partial<T> | null | undefined
|
||||
): T {
|
||||
if (!source) return { ...defaults };
|
||||
|
||||
const result = { ...defaults } as T;
|
||||
|
||||
for (const key of Object.keys(defaults) as Array<keyof T>) {
|
||||
const defaultValue = defaults[key];
|
||||
const sourceValue = source[key];
|
||||
|
||||
// Skip if source doesn't have this key
|
||||
if (!(key in source) || sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle nested objects recursively
|
||||
if (isObject(defaultValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMergeWithDefaults(
|
||||
defaultValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as T[keyof T];
|
||||
continue;
|
||||
}
|
||||
|
||||
// For primitives and arrays, only use source if types match
|
||||
if (typeof sourceValue === typeof defaultValue) {
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
// Special case: allow null for nullable fields
|
||||
else if (sourceValue === null && defaultValue === null) {
|
||||
result[key] = null as T[keyof T];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form values initializer from API data
|
||||
*
|
||||
* This is a higher-order function that creates a type-safe initializer
|
||||
* for transforming API responses into form values with defaults.
|
||||
*
|
||||
* @param defaults - Default form values
|
||||
* @param transform - Optional transform function for custom mapping
|
||||
* @returns Function that takes API data and returns form values
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const initializeAgentForm = createFormInitializer(
|
||||
* defaultAgentTypeValues,
|
||||
* (apiData, defaults) => ({
|
||||
* ...defaults,
|
||||
* name: apiData?.name ?? defaults.name,
|
||||
* model_params: deepMergeWithDefaults(
|
||||
* defaults.model_params,
|
||||
* apiData?.model_params
|
||||
* ),
|
||||
* })
|
||||
* );
|
||||
*
|
||||
* // Usage
|
||||
* const formValues = initializeAgentForm(apiResponse);
|
||||
* ```
|
||||
*/
|
||||
export function createFormInitializer<TForm, TApi = Partial<TForm>>(
|
||||
defaults: TForm,
|
||||
transform?: (apiData: TApi | null | undefined, defaults: TForm) => TForm
|
||||
): (apiData: TApi | null | undefined) => TForm {
|
||||
return (apiData) => {
|
||||
if (transform) {
|
||||
return transform(apiData, defaults);
|
||||
}
|
||||
// Default behavior: deep merge
|
||||
return deepMergeWithDefaults(
|
||||
defaults as Record<string, unknown>,
|
||||
apiData as Record<string, unknown> | null | undefined
|
||||
) as TForm;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user