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:
2026-01-06 13:50:36 +01:00
parent 6b21a6fadd
commit 3c6b14d2bf
11 changed files with 1303 additions and 114 deletions

View 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 };
}

View 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';

View 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;
}

View 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;
};
}