forked from cardosofelipe/fast-next-template
Compare commits
4 Commits
4c8f81368c
...
3c6b14d2bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6b14d2bf | ||
|
|
6b21a6fadd | ||
|
|
600657adc4 | ||
|
|
c9d0d079b3 |
@@ -9,7 +9,8 @@
|
||||
"fallback_models": ["claude-haiku-3-5-20241022"],
|
||||
"model_params": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
"max_tokens": 4096,
|
||||
"top_p": 0.95
|
||||
},
|
||||
"mcp_servers": ["gitea", "knowledge-base"],
|
||||
"tool_permissions": {
|
||||
@@ -29,7 +30,8 @@
|
||||
"fallback_models": ["claude-haiku-3-5-20241022"],
|
||||
"model_params": {
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 8192
|
||||
"max_tokens": 8192,
|
||||
"top_p": 0.95
|
||||
},
|
||||
"mcp_servers": ["gitea", "knowledge-base"],
|
||||
"tool_permissions": {
|
||||
@@ -49,7 +51,8 @@
|
||||
"fallback_models": ["claude-haiku-3-5-20241022"],
|
||||
"model_params": {
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 8192
|
||||
"max_tokens": 8192,
|
||||
"top_p": 0.95
|
||||
},
|
||||
"mcp_servers": ["gitea", "knowledge-base", "filesystem"],
|
||||
"tool_permissions": {
|
||||
@@ -69,7 +72,8 @@
|
||||
"fallback_models": ["claude-haiku-3-5-20241022"],
|
||||
"model_params": {
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 16384
|
||||
"max_tokens": 16384,
|
||||
"top_p": 0.95
|
||||
},
|
||||
"mcp_servers": ["gitea", "knowledge-base", "filesystem"],
|
||||
"tool_permissions": {
|
||||
@@ -89,7 +93,8 @@
|
||||
"fallback_models": ["claude-haiku-3-5-20241022"],
|
||||
"model_params": {
|
||||
"temperature": 0.4,
|
||||
"max_tokens": 8192
|
||||
"max_tokens": 8192,
|
||||
"top_p": 0.95
|
||||
},
|
||||
"mcp_servers": ["gitea", "knowledge-base", "filesystem"],
|
||||
"tool_permissions": {
|
||||
@@ -109,7 +114,8 @@
|
||||
"fallback_models": ["claude-haiku-3-5-20241022"],
|
||||
"model_params": {
|
||||
"temperature": 0.4,
|
||||
"max_tokens": 8192
|
||||
"max_tokens": 8192,
|
||||
"top_p": 0.95
|
||||
},
|
||||
"mcp_servers": ["gitea", "knowledge-base", "filesystem"],
|
||||
"tool_permissions": {
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
*
|
||||
* React Hook Form-based form for creating and editing agent types.
|
||||
* Features tabbed interface for organizing form sections.
|
||||
*
|
||||
* Uses reusable form utilities for:
|
||||
* - Validation error handling with toast notifications
|
||||
* - Safe API-to-form data transformation with defaults
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,15 +40,70 @@ import {
|
||||
generateSlug,
|
||||
} from '@/lib/validations/agentType';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
import { useValidationErrorHandler, deepMergeWithDefaults, isNumber } from '@/lib/forms';
|
||||
|
||||
interface AgentTypeFormProps {
|
||||
agentType?: AgentTypeResponse;
|
||||
onSubmit: (data: AgentTypeCreateFormValues) => void;
|
||||
onSubmit: (data: AgentTypeCreateFormValues) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
isSubmitting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Tab navigation mapping for validation errors
|
||||
const TAB_FIELD_MAPPING = {
|
||||
name: 'basic',
|
||||
slug: 'basic',
|
||||
description: 'basic',
|
||||
expertise: 'basic',
|
||||
is_active: 'basic',
|
||||
primary_model: 'model',
|
||||
fallback_models: 'model',
|
||||
model_params: 'model',
|
||||
mcp_servers: 'permissions',
|
||||
tool_permissions: 'permissions',
|
||||
personality_prompt: 'personality',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Transform API response to form values with safe defaults
|
||||
*
|
||||
* Uses deepMergeWithDefaults for most fields, with special handling
|
||||
* for model_params which needs numeric type validation.
|
||||
*/
|
||||
function transformAgentTypeToFormValues(
|
||||
agentType: AgentTypeResponse | undefined
|
||||
): AgentTypeCreateFormValues {
|
||||
if (!agentType) return defaultAgentTypeValues;
|
||||
|
||||
// model_params needs special handling for numeric validation
|
||||
const modelParams = agentType.model_params ?? {};
|
||||
const safeModelParams = {
|
||||
temperature: isNumber(modelParams.temperature) ? modelParams.temperature : 0.7,
|
||||
max_tokens: isNumber(modelParams.max_tokens) ? modelParams.max_tokens : 8192,
|
||||
top_p: isNumber(modelParams.top_p) ? modelParams.top_p : 0.95,
|
||||
};
|
||||
|
||||
// Merge with defaults, then override model_params with safe version
|
||||
const merged = deepMergeWithDefaults(defaultAgentTypeValues, {
|
||||
name: agentType.name,
|
||||
slug: agentType.slug,
|
||||
description: agentType.description,
|
||||
expertise: agentType.expertise,
|
||||
personality_prompt: agentType.personality_prompt,
|
||||
primary_model: agentType.primary_model,
|
||||
fallback_models: agentType.fallback_models,
|
||||
mcp_servers: agentType.mcp_servers,
|
||||
tool_permissions: agentType.tool_permissions,
|
||||
is_active: agentType.is_active,
|
||||
});
|
||||
|
||||
return {
|
||||
...merged,
|
||||
model_params: safeModelParams,
|
||||
};
|
||||
}
|
||||
|
||||
export function AgentTypeForm({
|
||||
agentType,
|
||||
onSubmit,
|
||||
@@ -56,28 +115,13 @@ export function AgentTypeForm({
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [expertiseInput, setExpertiseInput] = useState('');
|
||||
|
||||
// Memoize initial values transformation
|
||||
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
|
||||
|
||||
// Always use create schema for validation - editing requires all fields too
|
||||
const form = useForm<AgentTypeCreateFormValues>({
|
||||
resolver: zodResolver(agentTypeCreateSchema),
|
||||
defaultValues: agentType
|
||||
? {
|
||||
name: agentType.name,
|
||||
slug: agentType.slug,
|
||||
description: agentType.description,
|
||||
expertise: agentType.expertise,
|
||||
personality_prompt: agentType.personality_prompt,
|
||||
primary_model: agentType.primary_model,
|
||||
fallback_models: agentType.fallback_models,
|
||||
model_params: (agentType.model_params ?? {
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
top_p: 0.95,
|
||||
}) as AgentTypeCreateFormValues['model_params'],
|
||||
mcp_servers: agentType.mcp_servers,
|
||||
tool_permissions: agentType.tool_permissions,
|
||||
is_active: agentType.is_active,
|
||||
}
|
||||
: defaultAgentTypeValues,
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -89,12 +133,25 @@ export function AgentTypeForm({
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
// Use the reusable validation error handler hook
|
||||
const { onValidationError } = useValidationErrorHandler<AgentTypeCreateFormValues>({
|
||||
tabMapping: TAB_FIELD_MAPPING,
|
||||
setActiveTab,
|
||||
});
|
||||
|
||||
const watchName = watch('name');
|
||||
/* istanbul ignore next -- defensive fallback, expertise always has default */
|
||||
const watchExpertise = watch('expertise') || [];
|
||||
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
|
||||
const watchMcpServers = watch('mcp_servers') || [];
|
||||
|
||||
// Reset form when agentType changes (e.g., switching to edit mode)
|
||||
useEffect(() => {
|
||||
if (agentType) {
|
||||
form.reset(initialValues);
|
||||
}
|
||||
}, [agentType?.id, form, initialValues]);
|
||||
|
||||
// Auto-generate slug from name for new agent types
|
||||
useEffect(() => {
|
||||
if (!isEditing && watchName) {
|
||||
@@ -132,8 +189,16 @@ export function AgentTypeForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission with validation
|
||||
const onFormSubmit = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
return handleSubmit(onSubmit, onValidationError)(e);
|
||||
},
|
||||
[handleSubmit, onSubmit, onValidationError]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={className}>
|
||||
<form onSubmit={onFormSubmit} className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCancel}>
|
||||
|
||||
133
frontend/src/components/forms/FormSelect.tsx
Normal file
133
frontend/src/components/forms/FormSelect.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FormSelect Component
|
||||
*
|
||||
* Reusable Select field with Controller integration for react-hook-form.
|
||||
* Handles label, error display, and description automatically.
|
||||
*
|
||||
* @module components/forms/FormSelect
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Controller, type Control, type FieldValues, type Path } from 'react-hook-form';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FormSelectProps<T extends FieldValues> {
|
||||
/** Field name (must be a valid path in the form) */
|
||||
name: Path<T>;
|
||||
/** Form control from useForm */
|
||||
control: Control<T>;
|
||||
/** Field label */
|
||||
label: string;
|
||||
/** Available options */
|
||||
options: SelectOption[];
|
||||
/** Is field required? Shows asterisk if true */
|
||||
required?: boolean;
|
||||
/** Placeholder text when no value selected */
|
||||
placeholder?: string;
|
||||
/** Helper text below the field */
|
||||
description?: string;
|
||||
/** Disable the select */
|
||||
disabled?: boolean;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSelect - Controlled Select field for react-hook-form
|
||||
*
|
||||
* Automatically handles:
|
||||
* - Controller wrapper for react-hook-form
|
||||
* - Label with required indicator
|
||||
* - Error message display
|
||||
* - Description/helper text
|
||||
* - Accessibility attributes
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormSelect
|
||||
* name="primary_model"
|
||||
* control={form.control}
|
||||
* label="Primary Model"
|
||||
* required
|
||||
* options={[
|
||||
* { value: 'claude-opus', label: 'Claude Opus' },
|
||||
* { value: 'claude-sonnet', label: 'Claude Sonnet' },
|
||||
* ]}
|
||||
* description="Main model used for this agent"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FormSelect<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
label,
|
||||
options,
|
||||
required = false,
|
||||
placeholder,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
}: FormSelectProps<T>) {
|
||||
const selectId = String(name);
|
||||
const errorId = `${selectId}-error`;
|
||||
const descriptionId = description ? `${selectId}-description` : undefined;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={selectId}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
id={selectId}
|
||||
aria-invalid={!!fieldState.error}
|
||||
aria-describedby={
|
||||
[fieldState.error ? errorId : null, descriptionId].filter(Boolean).join(' ') ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={placeholder ?? `Select ${label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.error && (
|
||||
<p id={errorId} className="text-sm text-destructive" role="alert">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p id={descriptionId} className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/forms/FormTextarea.tsx
Normal file
101
frontend/src/components/forms/FormTextarea.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* FormTextarea Component
|
||||
*
|
||||
* Reusable Textarea field for react-hook-form with register integration.
|
||||
* Handles label, error display, and description automatically.
|
||||
*
|
||||
* @module components/forms/FormTextarea
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ComponentProps } from 'react';
|
||||
import type { FieldError, UseFormRegisterReturn } from 'react-hook-form';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export interface FormTextareaProps extends Omit<ComponentProps<typeof Textarea>, 'children'> {
|
||||
/** Field label */
|
||||
label: string;
|
||||
/** Field name (optional if provided via register) */
|
||||
name?: string;
|
||||
/** Is field required? Shows asterisk if true */
|
||||
required?: boolean;
|
||||
/** Form error from react-hook-form */
|
||||
error?: FieldError;
|
||||
/** Helper text below the field */
|
||||
description?: string;
|
||||
/** Register return object from useForm */
|
||||
registration?: UseFormRegisterReturn;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormTextarea - Textarea field for react-hook-form
|
||||
*
|
||||
* Automatically handles:
|
||||
* - Label with required indicator
|
||||
* - Error message display
|
||||
* - Description/helper text
|
||||
* - Accessibility attributes
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormTextarea
|
||||
* label="Personality Prompt"
|
||||
* required
|
||||
* error={errors.personality_prompt}
|
||||
* rows={10}
|
||||
* {...register('personality_prompt')}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FormTextarea({
|
||||
label,
|
||||
name: explicitName,
|
||||
required = false,
|
||||
error,
|
||||
description,
|
||||
registration,
|
||||
...textareaProps
|
||||
}: FormTextareaProps) {
|
||||
// Extract name from props or registration
|
||||
const registerName =
|
||||
'name' in textareaProps ? (textareaProps as { name: string }).name : undefined;
|
||||
const name = explicitName || registerName || registration?.name;
|
||||
|
||||
if (!name) {
|
||||
throw new Error('FormTextarea: name must be provided either explicitly or via register()');
|
||||
}
|
||||
|
||||
const errorId = error ? `${name}-error` : undefined;
|
||||
const descriptionId = description ? `${name}-description` : undefined;
|
||||
const ariaDescribedBy = [errorId, descriptionId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
// Merge registration props with other props
|
||||
const mergedProps = registration ? { ...registration, ...textareaProps } : textareaProps;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{description && (
|
||||
<p id={descriptionId} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<Textarea
|
||||
id={name}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
{...mergedProps}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
// Shared form components and utilities
|
||||
export { FormField } from './FormField';
|
||||
export type { FormFieldProps } from './FormField';
|
||||
export { FormSelect } from './FormSelect';
|
||||
export type { FormSelectProps, SelectOption } from './FormSelect';
|
||||
export { FormTextarea } from './FormTextarea';
|
||||
export type { FormTextareaProps } from './FormTextarea';
|
||||
export { useFormError } from './useFormError';
|
||||
export type { UseFormErrorReturn } from './useFormError';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tests for useValidationErrorHandler hook
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { toast } from 'sonner';
|
||||
import { useValidationErrorHandler } from '@/lib/forms/hooks/useValidationErrorHandler';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
|
||||
// Mock sonner toast
|
||||
jest.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock console.error to track debug logging
|
||||
const originalConsoleError = console.error;
|
||||
let consoleErrorMock: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorMock.mockRestore();
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
describe('useValidationErrorHandler', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('shows toast with first error message', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Please fix form errors', {
|
||||
description: 'name: Name is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom toast title when provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
toastTitle: 'Validation Failed',
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
email: { message: 'Invalid email', type: 'pattern' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Validation Failed', {
|
||||
description: 'email: Invalid email',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when no errors', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
result.current.onValidationError({});
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested errors', () => {
|
||||
it('handles nested field errors', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Temperature must be between 0 and 2', type: 'max' },
|
||||
},
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Please fix form errors', {
|
||||
description: 'model_params.temperature: Temperature must be between 0 and 2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab navigation', () => {
|
||||
it('navigates to correct tab when mapping provided', () => {
|
||||
const setActiveTab = jest.fn();
|
||||
const tabMapping = {
|
||||
name: 'basic',
|
||||
model_params: 'model',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
tabMapping,
|
||||
setActiveTab,
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Invalid', type: 'type' },
|
||||
},
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(setActiveTab).toHaveBeenCalledWith('model');
|
||||
});
|
||||
|
||||
it('does not navigate if field not in mapping', () => {
|
||||
const setActiveTab = jest.fn();
|
||||
const tabMapping = {
|
||||
name: 'basic',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
tabMapping,
|
||||
setActiveTab,
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
unknown_field: { message: 'Error', type: 'validation' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when setActiveTab not provided', () => {
|
||||
const tabMapping = { name: 'basic' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useValidationErrorHandler({
|
||||
tabMapping,
|
||||
// setActiveTab not provided
|
||||
debug: false,
|
||||
})
|
||||
);
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Required', type: 'required' },
|
||||
};
|
||||
|
||||
expect(() => result.current.onValidationError(errors)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug logging', () => {
|
||||
it('logs errors when debug is true', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: true }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Required', type: 'required' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(consoleErrorMock).toHaveBeenCalledWith('[Form Validation] Errors:', errors);
|
||||
});
|
||||
|
||||
it('does not log errors when debug is false', () => {
|
||||
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Required', type: 'required' },
|
||||
};
|
||||
|
||||
result.current.onValidationError(errors);
|
||||
|
||||
expect(consoleErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoization', () => {
|
||||
it('returns stable callback reference', () => {
|
||||
const { result, rerender } = renderHook(() => useValidationErrorHandler({ debug: false }));
|
||||
|
||||
const firstCallback = result.current.onValidationError;
|
||||
rerender();
|
||||
const secondCallback = result.current.onValidationError;
|
||||
|
||||
expect(firstCallback).toBe(secondCallback);
|
||||
});
|
||||
|
||||
it('returns new callback when options change', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ title }) => useValidationErrorHandler({ toastTitle: title, debug: false }),
|
||||
{ initialProps: { title: 'Error A' } }
|
||||
);
|
||||
|
||||
const firstCallback = result.current.onValidationError;
|
||||
rerender({ title: 'Error B' });
|
||||
const secondCallback = result.current.onValidationError;
|
||||
|
||||
expect(firstCallback).not.toBe(secondCallback);
|
||||
});
|
||||
});
|
||||
});
|
||||
134
frontend/tests/lib/forms/utils/getFirstValidationError.test.ts
Normal file
134
frontend/tests/lib/forms/utils/getFirstValidationError.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tests for getFirstValidationError utility
|
||||
*/
|
||||
|
||||
import {
|
||||
getFirstValidationError,
|
||||
getAllValidationErrors,
|
||||
} from '@/lib/forms/utils/getFirstValidationError';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
|
||||
describe('getFirstValidationError', () => {
|
||||
it('returns null for empty errors object', () => {
|
||||
const result = getFirstValidationError({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts direct error message', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({ field: 'name', message: 'Name is required' });
|
||||
});
|
||||
|
||||
it('extracts nested error message', () => {
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Temperature must be a number', type: 'type' },
|
||||
},
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({
|
||||
field: 'model_params.temperature',
|
||||
message: 'Temperature must be a number',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns first error when multiple fields have errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
// Object.keys order is insertion order, so 'name' comes first
|
||||
expect(result?.field).toBe('name');
|
||||
expect(result?.message).toBe('Name is required');
|
||||
});
|
||||
|
||||
it('handles deeply nested errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
config: {
|
||||
nested: {
|
||||
deep: { message: 'Deep error', type: 'validation' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({ field: 'config.nested.deep', message: 'Deep error' });
|
||||
});
|
||||
|
||||
it('skips null error entries', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: null as unknown as undefined,
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
};
|
||||
const result = getFirstValidationError(errors);
|
||||
expect(result).toEqual({ field: 'slug', message: 'Slug is required' });
|
||||
});
|
||||
|
||||
it('handles error object with ref but no message', () => {
|
||||
// react-hook-form errors may have 'ref' property but no 'message'
|
||||
// We cast to FieldErrors to simulate edge cases
|
||||
const errors = {
|
||||
name: { type: 'required', ref: { current: null } },
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
} as unknown as FieldErrors;
|
||||
const result = getFirstValidationError(errors);
|
||||
// Should skip name (no message) and find slug
|
||||
expect(result).toEqual({ field: 'slug', message: 'Slug is required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllValidationErrors', () => {
|
||||
it('returns empty array for empty errors object', () => {
|
||||
const result = getAllValidationErrors({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all errors as flat array', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
slug: { message: 'Slug is required', type: 'required' },
|
||||
};
|
||||
const result = getAllValidationErrors(errors);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({ field: 'name', message: 'Name is required' });
|
||||
expect(result).toContainEqual({ field: 'slug', message: 'Slug is required' });
|
||||
});
|
||||
|
||||
it('flattens nested errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
model_params: {
|
||||
temperature: { message: 'Invalid temperature', type: 'type' },
|
||||
max_tokens: { message: 'Invalid max tokens', type: 'type' },
|
||||
},
|
||||
};
|
||||
const result = getAllValidationErrors(errors);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({
|
||||
field: 'model_params.temperature',
|
||||
message: 'Invalid temperature',
|
||||
});
|
||||
expect(result).toContainEqual({
|
||||
field: 'model_params.max_tokens',
|
||||
message: 'Invalid max tokens',
|
||||
});
|
||||
});
|
||||
|
||||
it('combines direct and nested errors', () => {
|
||||
const errors: FieldErrors = {
|
||||
name: { message: 'Name is required', type: 'required' },
|
||||
model_params: {
|
||||
temperature: { message: 'Invalid temperature', type: 'type' },
|
||||
},
|
||||
};
|
||||
const result = getAllValidationErrors(errors);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({ field: 'name', message: 'Name is required' });
|
||||
expect(result).toContainEqual({
|
||||
field: 'model_params.temperature',
|
||||
message: 'Invalid temperature',
|
||||
});
|
||||
});
|
||||
});
|
||||
247
frontend/tests/lib/forms/utils/mergeWithDefaults.test.ts
Normal file
247
frontend/tests/lib/forms/utils/mergeWithDefaults.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Tests for mergeWithDefaults utilities
|
||||
*/
|
||||
|
||||
import {
|
||||
safeValue,
|
||||
isNumber,
|
||||
isString,
|
||||
isBoolean,
|
||||
isArray,
|
||||
isObject,
|
||||
deepMergeWithDefaults,
|
||||
createFormInitializer,
|
||||
} from '@/lib/forms/utils/mergeWithDefaults';
|
||||
|
||||
describe('Type Guards', () => {
|
||||
describe('isNumber', () => {
|
||||
it('returns true for valid numbers', () => {
|
||||
expect(isNumber(0)).toBe(true);
|
||||
expect(isNumber(42)).toBe(true);
|
||||
expect(isNumber(-10)).toBe(true);
|
||||
expect(isNumber(3.14)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for NaN', () => {
|
||||
expect(isNumber(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-numbers', () => {
|
||||
expect(isNumber('42')).toBe(false);
|
||||
expect(isNumber(null)).toBe(false);
|
||||
expect(isNumber(undefined)).toBe(false);
|
||||
expect(isNumber({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isString', () => {
|
||||
it('returns true for strings', () => {
|
||||
expect(isString('')).toBe(true);
|
||||
expect(isString('hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-strings', () => {
|
||||
expect(isString(42)).toBe(false);
|
||||
expect(isString(null)).toBe(false);
|
||||
expect(isString(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBoolean', () => {
|
||||
it('returns true for booleans', () => {
|
||||
expect(isBoolean(true)).toBe(true);
|
||||
expect(isBoolean(false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-booleans', () => {
|
||||
expect(isBoolean(0)).toBe(false);
|
||||
expect(isBoolean(1)).toBe(false);
|
||||
expect(isBoolean('true')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isArray', () => {
|
||||
it('returns true for arrays', () => {
|
||||
expect(isArray([])).toBe(true);
|
||||
expect(isArray([1, 2, 3])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-arrays', () => {
|
||||
expect(isArray({})).toBe(false);
|
||||
expect(isArray('array')).toBe(false);
|
||||
expect(isArray(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('validates item types when itemCheck provided', () => {
|
||||
expect(isArray([1, 2, 3], isNumber)).toBe(true);
|
||||
expect(isArray(['a', 'b'], isString)).toBe(true);
|
||||
expect(isArray([1, 'two', 3], isNumber)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isObject', () => {
|
||||
it('returns true for plain objects', () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ key: 'value' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for arrays', () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for primitives', () => {
|
||||
expect(isObject('string')).toBe(false);
|
||||
expect(isObject(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeValue', () => {
|
||||
it('returns value when type check passes', () => {
|
||||
expect(safeValue(42, 0, isNumber)).toBe(42);
|
||||
expect(safeValue('hello', '', isString)).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns default when type check fails', () => {
|
||||
expect(safeValue('not a number', 0, isNumber)).toBe(0);
|
||||
expect(safeValue(42, '', isString)).toBe('');
|
||||
});
|
||||
|
||||
it('returns default for null/undefined', () => {
|
||||
expect(safeValue(null, 0, isNumber)).toBe(0);
|
||||
expect(safeValue(undefined, 'default', isString)).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMergeWithDefaults', () => {
|
||||
it('returns defaults when source is null', () => {
|
||||
const defaults = { name: 'default', value: 10 };
|
||||
expect(deepMergeWithDefaults(defaults, null)).toEqual(defaults);
|
||||
});
|
||||
|
||||
it('returns defaults when source is undefined', () => {
|
||||
const defaults = { name: 'default', value: 10 };
|
||||
expect(deepMergeWithDefaults(defaults, undefined)).toEqual(defaults);
|
||||
});
|
||||
|
||||
it('merges source values over defaults', () => {
|
||||
const defaults = { name: 'default', value: 10 };
|
||||
const source = { name: 'custom' };
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({
|
||||
name: 'custom',
|
||||
value: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves default for missing source keys', () => {
|
||||
const defaults = { a: 1, b: 2, c: 3 };
|
||||
const source = { a: 10 };
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({
|
||||
a: 10,
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('recursively merges nested objects', () => {
|
||||
const defaults = {
|
||||
config: { temperature: 0.7, max_tokens: 8192 },
|
||||
};
|
||||
// Source has partial nested config - deepMerge fills in missing fields
|
||||
const source = {
|
||||
config: { temperature: 0.5 },
|
||||
} as unknown as Partial<typeof defaults>;
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({
|
||||
config: { temperature: 0.5, max_tokens: 8192 },
|
||||
});
|
||||
});
|
||||
|
||||
it('only uses source values if types match', () => {
|
||||
const defaults = { value: 10, name: 'default' };
|
||||
const source = { value: 'not a number' as unknown as number };
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({
|
||||
value: 10,
|
||||
name: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles arrays - uses source array if types match', () => {
|
||||
const defaults = { items: ['a', 'b'] };
|
||||
const source = { items: ['c', 'd', 'e'] };
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({
|
||||
items: ['c', 'd', 'e'],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips undefined source values', () => {
|
||||
const defaults = { name: 'default' };
|
||||
const source = { name: undefined };
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({
|
||||
name: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null values when defaults are null', () => {
|
||||
const defaults = { value: null };
|
||||
const source = { value: null };
|
||||
expect(deepMergeWithDefaults(defaults, source)).toEqual({ value: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFormInitializer', () => {
|
||||
it('returns defaults when called with null', () => {
|
||||
const defaults = { name: '', age: 0 };
|
||||
const initializer = createFormInitializer(defaults);
|
||||
expect(initializer(null)).toEqual(defaults);
|
||||
});
|
||||
|
||||
it('returns defaults when called with undefined', () => {
|
||||
const defaults = { name: '', age: 0 };
|
||||
const initializer = createFormInitializer(defaults);
|
||||
expect(initializer(undefined)).toEqual(defaults);
|
||||
});
|
||||
|
||||
it('merges API data with defaults', () => {
|
||||
const defaults = { name: '', age: 0, active: false };
|
||||
const initializer = createFormInitializer(defaults);
|
||||
const result = initializer({ name: 'John', age: 25 });
|
||||
expect(result).toEqual({ name: 'John', age: 25, active: false });
|
||||
});
|
||||
|
||||
it('uses custom transform function when provided', () => {
|
||||
interface Form {
|
||||
fullName: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
interface Api {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const defaults: Form = { fullName: '', isActive: false };
|
||||
const initializer = createFormInitializer<Form, Api>(defaults, (apiData, defs) => ({
|
||||
fullName: apiData ? `${apiData.first_name} ${apiData.last_name}` : defs.fullName,
|
||||
isActive: apiData?.active ?? defs.isActive,
|
||||
}));
|
||||
|
||||
const result = initializer({
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
active: true,
|
||||
});
|
||||
expect(result).toEqual({ fullName: 'John Doe', isActive: true });
|
||||
});
|
||||
|
||||
it('transform receives defaults when apiData is null', () => {
|
||||
const defaults = { name: 'default' };
|
||||
const initializer = createFormInitializer(defaults, (_, defs) => ({
|
||||
name: defs.name.toUpperCase(),
|
||||
}));
|
||||
expect(initializer(null)).toEqual({ name: 'DEFAULT' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user