Files
zclaw_openfang/desktop/src/components/HandParamsForm.tsx
iven 3e81bd3e50 feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement
- Change tabs to icon + small label layout for better space utilization
- Add Teams tab with team collaboration entry point

## Settings Page Improvements
- Connect theme toggle to gatewayStore.saveQuickConfig for persistence
- Remove OpenFang backend download section, simplify UI
- Add time range filter to UsageStats (7d/30d/all)
- Add stat cards with icons (sessions, messages, input/output tokens)
- Add token usage overview bar chart
- Add 8 ZCLAW system skill definitions with categories

## Bug Fixes
- Fix ChannelList duplicate content with deduplication logic
- Integrate CreateTriggerModal in TriggersPanel
- Add independent SecurityStatusPanel with 12 default enabled layers
- Change workflow view to use SchedulerPanel as unified entry

## New Components
- CreateTriggerModal: Event trigger creation modal
- HandApprovalModal: Hand approval workflow dialog
- HandParamsForm: Enhanced Hand parameter form
- SecurityLayersPanel: 16-layer security status display

## Architecture
- Add TOML config parsing support (toml-utils.ts, config-parser.ts)
- Add request timeout and retry mechanism (request-helper.ts)
- Add secure token storage (secure-storage.ts, secure_storage.rs)

## Tests
- Add unit tests for config-parser, toml-utils, request-helper
- Add team-client and teamStore tests

## Documentation
- Update SYSTEM_ANALYSIS.md with Phase 8 completion
- UI completion: 100% (30/30 components)
- API coverage: 93% (63/68 endpoints)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:12:11 +08:00

813 lines
26 KiB
TypeScript

/**
* HandParamsForm - Dynamic form component for Hand parameters
*
* Supports all parameter types:
* - text: Text input
* - number: Number input with min/max validation
* - boolean: Toggle/checkbox
* - select: Dropdown select
* - textarea: Multi-line text
* - array: Dynamic array with add/remove items
* - object: JSON object editor
* - file: File selector
*
* Features:
* - Form validation (required, type, range, pattern)
* - Parameter presets (save/load/delete)
* - Error display below inputs
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import {
AlertCircle,
Plus,
Trash2,
Save,
FolderOpen,
Trash,
ChevronDown,
ChevronUp,
FileText,
Info,
} from 'lucide-react';
import type { HandParameter } from '../types/hands';
// === Types ===
export interface HandParamsFormProps {
parameters: HandParameter[];
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
errors?: Record<string, string>;
disabled?: boolean;
presetKey?: string; // Key for storing/loading presets
}
export interface ParameterPreset {
id: string;
name: string;
createdAt: string;
values: Record<string, unknown>;
}
// === Validation ===
interface ValidationResult {
isValid: boolean;
error?: string;
}
function validateParameter(param: HandParameter, value: unknown): ValidationResult {
// Required check
if (param.required) {
if (value === undefined || value === null || value === '') {
return { isValid: false, error: `${param.label} is required` };
}
if (Array.isArray(value) && value.length === 0) {
return { isValid: false, error: `${param.label} is required` };
}
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
return { isValid: false, error: `${param.label} is required` };
}
}
// Skip further validation if value is empty and not required
if (value === undefined || value === null || value === '') {
return { isValid: true };
}
// Type-specific validation
switch (param.type) {
case 'number':
if (typeof value !== 'number' || isNaN(value)) {
return { isValid: false, error: `${param.label} must be a valid number` };
}
if (param.min !== undefined && value < param.min) {
return { isValid: false, error: `${param.label} must be at least ${param.min}` };
}
if (param.max !== undefined && value > param.max) {
return { isValid: false, error: `${param.label} must be at most ${param.max}` };
}
break;
case 'text':
case 'textarea':
if (typeof value !== 'string') {
return { isValid: false, error: `${param.label} must be text` };
}
if (param.pattern) {
try {
const regex = new RegExp(param.pattern);
if (!regex.test(value)) {
return { isValid: false, error: `${param.label} format is invalid` };
}
} catch {
// Invalid regex pattern, skip validation
}
}
break;
case 'array':
if (!Array.isArray(value)) {
return { isValid: false, error: `${param.label} must be an array` };
}
break;
case 'object':
if (typeof value !== 'object' || Array.isArray(value)) {
return { isValid: false, error: `${param.label} must be an object` };
}
try {
// Try to stringify to validate JSON
JSON.stringify(value);
} catch {
return { isValid: false, error: `${param.label} contains invalid JSON` };
}
break;
case 'file':
if (typeof value !== 'string') {
return { isValid: false, error: `${param.label} must be a file path` };
}
break;
}
return { isValid: true };
}
// === Preset Storage ===
const PRESET_STORAGE_PREFIX = 'zclaw-hand-preset-';
function getPresetStorageKey(handId: string): string {
return `${PRESET_STORAGE_PREFIX}${handId}`;
}
function loadPresets(handId: string): ParameterPreset[] {
try {
const stored = localStorage.getItem(getPresetStorageKey(handId));
if (stored) {
return JSON.parse(stored) as ParameterPreset[];
}
} catch {
// Ignore parse errors
}
return [];
}
function savePresets(handId: string, presets: ParameterPreset[]): void {
try {
localStorage.setItem(getPresetStorageKey(handId), JSON.stringify(presets));
} catch {
// Ignore storage errors
}
}
// === Sub-Components ===
interface FormFieldWrapperProps {
param: HandParameter;
error?: string;
children: React.ReactNode;
}
function FormFieldWrapper({ param, error, children }: FormFieldWrapperProps) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{param.label}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
{children}
{param.description && !error && (
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Info className="w-3 h-3" />
{param.description}
</p>
)}
{error && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{error}
</p>
)}
</div>
);
}
// === Parameter Input Components ===
interface ParamInputProps {
param: HandParameter;
value: unknown;
onChange: (value: unknown) => void;
disabled?: boolean;
error?: string;
}
function TextParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
return (
<input
type="text"
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={param.placeholder}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
);
}
function NumberParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={(e) => {
const val = e.target.value;
onChange(val === '' ? undefined : parseFloat(val));
}}
placeholder={param.placeholder}
min={param.min}
max={param.max}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
);
}
function BooleanParamInput({ param, value, onChange, disabled }: ParamInputProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(value as boolean) ?? false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{param.placeholder || 'Enabled'}
</span>
</label>
);
}
function SelectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
return (
<select
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
>
<option value="">{param.placeholder || '-- Select --'}</option>
{param.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
function TextareaParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
return (
<textarea
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={param.placeholder}
disabled={disabled}
rows={3}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
);
}
function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
const [newItem, setNewItem] = useState('');
const items = (Array.isArray(value) ? value : []) as string[];
const handleAddItem = () => {
if (newItem.trim()) {
onChange([...items, newItem.trim()]);
setNewItem('');
}
};
const handleRemoveItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
};
const handleUpdateItem = (index: number, newValue: string) => {
const newItems = [...items];
newItems[index] = newValue;
onChange(newItems);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddItem();
}
};
return (
<div className={`space-y-2 p-3 border rounded-lg ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}>
{/* Existing Items */}
{items.length > 0 && (
<div className="space-y-1.5">
{items.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={item}
onChange={(e) => handleUpdateItem(index, e.target.value)}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={() => handleRemoveItem(index)}
disabled={disabled}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Add New Item */}
<div className="flex items-center gap-2">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={param.placeholder || 'Add item...'}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={handleAddItem}
disabled={disabled || !newItem.trim()}
className="p-1 text-blue-500 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
</button>
</div>
{items.length === 0 && !newItem && (
<p className="text-xs text-gray-400 text-center">No items added yet</p>
)}
</div>
);
}
function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
const [jsonText, setJsonText] = useState('');
const [parseError, setParseError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(true);
// Sync jsonText with value
useEffect(() => {
try {
if (value && typeof value === 'object' && !Array.isArray(value)) {
setJsonText(JSON.stringify(value, null, 2));
setParseError(null);
} else {
setJsonText('');
}
} catch {
setJsonText('');
}
}, [value]);
const handleTextChange = (text: string) => {
setJsonText(text);
if (!text.trim()) {
onChange({});
setParseError(null);
return;
}
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
onChange(parsed);
setParseError(null);
} else {
setParseError('Value must be a JSON object');
}
} catch {
setParseError('Invalid JSON format');
}
};
return (
<div className={`space-y-2 ${error || parseError ? 'border-red-500' : ''}`}>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{isExpanded ? 'Collapse' : 'Expand'} JSON Editor
</button>
{isExpanded && (
<textarea
value={jsonText}
onChange={(e) => handleTextChange(e.target.value)}
placeholder={param.placeholder || '{\n "key": "value"\n}'}
disabled={disabled}
rows={6}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
error || parseError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
)}
{parseError && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{parseError}
</p>
)}
</div>
);
}
function FileParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// For now, just store the file name. In a real implementation,
// you might want to read the file contents or handle upload
onChange(file.name);
}
};
return (
<div className={`space-y-2 ${error ? 'border-red-500' : ''}`}>
<div className="flex items-center gap-2">
<label
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm border rounded-lg cursor-pointer transition-colors ${
disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-50'
: 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
>
<FileText className="w-4 h-4 text-gray-400" />
<span className="flex-1 truncate text-gray-900 dark:text-white">
{(value as string) || param.placeholder || 'Choose file...'}
</span>
<input
type="file"
accept={param.accept}
onChange={handleFileChange}
disabled={disabled}
className="hidden"
/>
</label>
{(value as string) && (
<button
type="button"
onClick={() => onChange('')}
disabled={disabled}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}
// === Parameter Field Component ===
function ParameterField({ param, value, onChange, disabled, externalError }: ParamInputProps & { externalError?: string }) {
const [internalError, setInternalError] = useState<string | undefined>(undefined);
const handleChange = useCallback((newValue: unknown) => {
// Validate on change
const result = validateParameter(param, newValue);
setInternalError(result.error ?? undefined);
onChange(newValue);
}, [param, onChange]);
const error = externalError || internalError;
const inputProps: ParamInputProps = { param, value, onChange: handleChange, disabled, error };
const renderInput = () => {
switch (param.type) {
case 'text':
return <TextParamInput {...inputProps} />;
case 'number':
return <NumberParamInput {...inputProps} />;
case 'boolean':
return <BooleanParamInput {...inputProps} />;
case 'select':
return <SelectParamInput {...inputProps} />;
case 'textarea':
return <TextareaParamInput {...inputProps} />;
case 'array':
return <ArrayParamInput {...inputProps} />;
case 'object':
return <ObjectParamInput {...inputProps} />;
case 'file':
return <FileParamInput {...inputProps} />;
default:
return <TextParamInput {...inputProps} />;
}
};
return (
<FormFieldWrapper param={param} error={error}>
{renderInput()}
</FormFieldWrapper>
);
}
// === Preset Manager Component ===
interface PresetManagerProps {
presetKey?: string;
currentValues: Record<string, unknown>;
onLoadPreset: (values: Record<string, unknown>) => void;
}
function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManagerProps) {
const [presets, setPresets] = useState<ParameterPreset[]>([]);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [presetName, setPresetName] = useState('');
const [showPresetList, setShowPresetList] = useState(false);
// Load presets on mount
useEffect(() => {
if (presetKey) {
setPresets(loadPresets(presetKey));
}
}, [presetKey]);
const handleSavePreset = () => {
if (!presetKey || !presetName.trim()) return;
const newPreset: ParameterPreset = {
id: `preset-${Date.now()}`,
name: presetName.trim(),
createdAt: new Date().toISOString(),
values: { ...currentValues },
};
const newPresets = [...presets, newPreset];
setPresets(newPresets);
savePresets(presetKey, newPresets);
setPresetName('');
setShowSaveDialog(false);
};
const handleLoadPreset = (preset: ParameterPreset) => {
onLoadPreset(preset.values);
setShowPresetList(false);
};
const handleDeletePreset = (presetId: string) => {
if (!presetKey) return;
const newPresets = presets.filter((p) => p.id !== presetId);
setPresets(newPresets);
savePresets(presetKey, newPresets);
};
if (!presetKey) return null;
return (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowSaveDialog(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Save className="w-3.5 h-3.5" />
Save Preset
</button>
<button
type="button"
onClick={() => setShowPresetList(!showPresetList)}
disabled={presets.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Preset ({presets.length})
</button>
</div>
{/* Save Dialog */}
{showSaveDialog && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Preset Name
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="My preset..."
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePreset();
if (e.key === 'Escape') setShowSaveDialog(false);
}}
autoFocus
/>
<button
type="button"
onClick={handleSavePreset}
disabled={!presetName.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
<button
type="button"
onClick={() => setShowSaveDialog(false)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Cancel
</button>
</div>
</div>
)}
{/* Preset List */}
{showPresetList && presets.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Available Presets
</label>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{presets.map((preset) => (
<div
key={preset.id}
className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{preset.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{new Date(preset.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-1 ml-2">
<button
type="button"
onClick={() => handleLoadPreset(preset)}
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
>
Load
</button>
<button
type="button"
onClick={() => handleDeletePreset(preset.id)}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
// === Main Component ===
export function HandParamsForm({
parameters,
values,
onChange,
errors,
disabled,
presetKey,
}: HandParamsFormProps) {
// Initialize values with defaults
const initialValues = useMemo(() => {
const result: Record<string, unknown> = { ...values };
parameters.forEach((param) => {
if (result[param.name] === undefined && param.defaultValue !== undefined) {
result[param.name] = param.defaultValue;
}
});
return result;
}, [parameters, values]);
// Update parent when initialValues changes
useEffect(() => {
const hasMissingDefaults = parameters.some(
(p) => values[p.name] === undefined && p.defaultValue !== undefined
);
if (hasMissingDefaults) {
onChange(initialValues);
}
}, [initialValues, parameters, values, onChange]);
const handleFieldChange = useCallback(
(paramName: string, value: unknown) => {
onChange({
...values,
[paramName]: value,
});
},
[values, onChange]
);
const handleLoadPreset = useCallback(
(presetValues: Record<string, unknown>) => {
onChange({
...values,
...presetValues,
});
},
[values, onChange]
);
if (parameters.length === 0) {
return (
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
No parameters required for this Hand.
</div>
);
}
return (
<div className="space-y-4">
{/* Preset Manager */}
<PresetManager
presetKey={presetKey}
currentValues={values}
onLoadPreset={handleLoadPreset}
/>
{/* Parameter Fields - Grid Layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{parameters.map((param) => (
<div
key={param.name}
className={param.type === 'textarea' || param.type === 'object' || param.type === 'array' ? 'md:col-span-2' : ''}
>
<ParameterField
param={param}
value={values[param.name]}
onChange={(value) => handleFieldChange(param.name, value)}
disabled={disabled}
externalError={errors?.[param.name]}
/>
</div>
))}
</div>
</div>
);
}
// === Validation Export ===
export function validateAllParameters(
parameters: HandParameter[],
values: Record<string, unknown>
): Record<string, string> {
const errors: Record<string, string> = {};
parameters.forEach((param) => {
const result = validateParameter(param, values[param.name]);
if (!result.isValid && result.error) {
errors[param.name] = result.error;
}
});
return errors;
}
export default HandParamsForm;