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:
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user