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:
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