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)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user