|
|
import React, { useState, useEffect, useMemo } from 'react'; |
|
|
import { useTranslation } from 'react-i18next'; |
|
|
import { ToolInputSchema } from '@/types'; |
|
|
|
|
|
interface JsonSchema { |
|
|
type: string; |
|
|
properties?: Record<string, JsonSchema>; |
|
|
required?: string[]; |
|
|
items?: JsonSchema; |
|
|
enum?: any[]; |
|
|
description?: string; |
|
|
default?: any; |
|
|
} |
|
|
|
|
|
interface DynamicFormProps { |
|
|
schema: ToolInputSchema; |
|
|
onSubmit: (values: Record<string, any>) => void; |
|
|
onCancel: () => void; |
|
|
loading?: boolean; |
|
|
storageKey?: string; |
|
|
} |
|
|
|
|
|
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => { |
|
|
const { t } = useTranslation(); |
|
|
const [formValues, setFormValues] = useState<Record<string, any>>({}); |
|
|
const [errors, setErrors] = useState<Record<string, string>>({}); |
|
|
|
|
|
|
|
|
const jsonSchema = useMemo(() => { |
|
|
const convertToJsonSchema = (schema: ToolInputSchema): JsonSchema => { |
|
|
const convertProperty = (prop: unknown): JsonSchema => { |
|
|
if (typeof prop === 'object' && prop !== null) { |
|
|
const obj = prop as any; |
|
|
return { |
|
|
type: obj.type || 'string', |
|
|
description: obj.description, |
|
|
enum: obj.enum, |
|
|
default: obj.default, |
|
|
properties: obj.properties ? Object.fromEntries( |
|
|
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)]) |
|
|
) : undefined, |
|
|
required: obj.required, |
|
|
items: obj.items ? convertProperty(obj.items) : undefined, |
|
|
}; |
|
|
} |
|
|
return { type: 'string' }; |
|
|
}; |
|
|
|
|
|
return { |
|
|
type: schema.type, |
|
|
properties: schema.properties ? Object.fromEntries( |
|
|
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)]) |
|
|
) : undefined, |
|
|
required: schema.required, |
|
|
}; |
|
|
}; |
|
|
|
|
|
return convertToJsonSchema(schema); |
|
|
}, [schema]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const initializeValues = (schema: JsonSchema, path: string = ''): Record<string, any> => { |
|
|
const values: Record<string, any> = {}; |
|
|
|
|
|
if (schema.type === 'object' && schema.properties) { |
|
|
Object.entries(schema.properties).forEach(([key, propSchema]) => { |
|
|
const fullPath = path ? `${path}.${key}` : key; |
|
|
if (propSchema.default !== undefined) { |
|
|
values[key] = propSchema.default; |
|
|
} else if (propSchema.type === 'string') { |
|
|
values[key] = ''; |
|
|
} else if (propSchema.type === 'number' || propSchema.type === 'integer') { |
|
|
values[key] = 0; |
|
|
} else if (propSchema.type === 'boolean') { |
|
|
values[key] = false; |
|
|
} else if (propSchema.type === 'array') { |
|
|
values[key] = []; |
|
|
} else if (propSchema.type === 'object') { |
|
|
values[key] = initializeValues(propSchema, fullPath); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
return values; |
|
|
}; |
|
|
|
|
|
let initialValues = initializeValues(jsonSchema); |
|
|
|
|
|
|
|
|
if (storageKey) { |
|
|
try { |
|
|
const savedData = localStorage.getItem(storageKey); |
|
|
if (savedData) { |
|
|
const parsedData = JSON.parse(savedData); |
|
|
|
|
|
initialValues = { ...initialValues, ...parsedData }; |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn('Failed to load saved form data:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
setFormValues(initialValues); |
|
|
}, [jsonSchema, storageKey]); |
|
|
|
|
|
const handleInputChange = (path: string, value: any) => { |
|
|
setFormValues(prev => { |
|
|
const newValues = { ...prev }; |
|
|
const keys = path.split('.'); |
|
|
let current = newValues; |
|
|
|
|
|
for (let i = 0; i < keys.length - 1; i++) { |
|
|
if (!current[keys[i]]) { |
|
|
current[keys[i]] = {}; |
|
|
} |
|
|
current = current[keys[i]]; |
|
|
} |
|
|
|
|
|
current[keys[keys.length - 1]] = value; |
|
|
|
|
|
|
|
|
if (storageKey) { |
|
|
try { |
|
|
localStorage.setItem(storageKey, JSON.stringify(newValues)); |
|
|
} catch (error) { |
|
|
console.warn('Failed to save form data to localStorage:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
return newValues; |
|
|
}); |
|
|
|
|
|
|
|
|
if (errors[path]) { |
|
|
setErrors(prev => { |
|
|
const newErrors = { ...prev }; |
|
|
delete newErrors[path]; |
|
|
return newErrors; |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
const validateForm = (): boolean => { |
|
|
const newErrors: Record<string, string> = {}; |
|
|
|
|
|
const validateObject = (schema: JsonSchema, values: any, path: string = '') => { |
|
|
if (schema.type === 'object' && schema.properties) { |
|
|
Object.entries(schema.properties).forEach(([key, propSchema]) => { |
|
|
const fullPath = path ? `${path}.${key}` : key; |
|
|
const value = values?.[key]; |
|
|
|
|
|
|
|
|
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) { |
|
|
newErrors[fullPath] = `${key} is required`; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (value !== undefined && value !== null && value !== '') { |
|
|
if (propSchema.type === 'string' && typeof value !== 'string') { |
|
|
newErrors[fullPath] = `${key} must be a string`; |
|
|
} else if (propSchema.type === 'number' && typeof value !== 'number') { |
|
|
newErrors[fullPath] = `${key} must be a number`; |
|
|
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) { |
|
|
newErrors[fullPath] = `${key} must be an integer`; |
|
|
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') { |
|
|
newErrors[fullPath] = `${key} must be a boolean`; |
|
|
} else if (propSchema.type === 'object' && typeof value === 'object') { |
|
|
validateObject(propSchema, value, fullPath); |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
validateObject(jsonSchema, formValues); |
|
|
setErrors(newErrors); |
|
|
return Object.keys(newErrors).length === 0; |
|
|
}; |
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => { |
|
|
e.preventDefault(); |
|
|
if (validateForm()) { |
|
|
onSubmit(formValues); |
|
|
} |
|
|
}; |
|
|
|
|
|
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => { |
|
|
const fullPath = path ? `${path}.${key}` : key; |
|
|
const value = formValues[key]; |
|
|
const error = errors[fullPath]; |
|
|
|
|
|
if (propSchema.type === 'string') { |
|
|
if (propSchema.enum) { |
|
|
return ( |
|
|
<div key={fullPath} className="mb-4"> |
|
|
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|
|
{key} |
|
|
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>} |
|
|
</label> |
|
|
{propSchema.description && ( |
|
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> |
|
|
)} |
|
|
<select |
|
|
value={value || ''} |
|
|
onChange={(e) => handleInputChange(fullPath, e.target.value)} |
|
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`} |
|
|
> |
|
|
<option value="">{t('tool.selectOption')}</option> |
|
|
{propSchema.enum.map((option, idx) => ( |
|
|
<option key={idx} value={option}> |
|
|
{option} |
|
|
</option> |
|
|
))} |
|
|
</select> |
|
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>} |
|
|
</div> |
|
|
); |
|
|
} else { |
|
|
return ( |
|
|
<div key={fullPath} className="mb-4"> |
|
|
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|
|
{key} |
|
|
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>} |
|
|
</label> |
|
|
{propSchema.description && ( |
|
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> |
|
|
)} |
|
|
<input |
|
|
type="text" |
|
|
value={value || ''} |
|
|
onChange={(e) => handleInputChange(fullPath, e.target.value)} |
|
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`} |
|
|
/> |
|
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
if (propSchema.type === 'number' || propSchema.type === 'integer') { |
|
|
return ( |
|
|
<div key={fullPath} className="mb-4"> |
|
|
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|
|
{key} |
|
|
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>} |
|
|
</label> |
|
|
{propSchema.description && ( |
|
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> |
|
|
)} |
|
|
<input |
|
|
type="number" |
|
|
step={propSchema.type === 'integer' ? '1' : 'any'} |
|
|
value={value || ''} |
|
|
onChange={(e) => { |
|
|
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value); |
|
|
handleInputChange(fullPath, val); |
|
|
}} |
|
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`} |
|
|
/> |
|
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
if (propSchema.type === 'boolean') { |
|
|
return ( |
|
|
<div key={fullPath} className="mb-4"> |
|
|
<div className="flex items-center"> |
|
|
<input |
|
|
type="checkbox" |
|
|
checked={value || false} |
|
|
onChange={(e) => handleInputChange(fullPath, e.target.checked)} |
|
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" |
|
|
/> |
|
|
<label className="ml-2 block text-sm text-gray-700"> |
|
|
{key} |
|
|
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>} |
|
|
</label> |
|
|
</div> |
|
|
{propSchema.description && ( |
|
|
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p> |
|
|
)} |
|
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
return ( |
|
|
<div key={fullPath} className="mb-4"> |
|
|
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|
|
{key} |
|
|
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>} |
|
|
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span> |
|
|
</label> |
|
|
{propSchema.description && ( |
|
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> |
|
|
)} |
|
|
<input |
|
|
type="text" |
|
|
value={value || ''} |
|
|
onChange={(e) => handleInputChange(fullPath, e.target.value)} |
|
|
placeholder={t('tool.enterValue', { type: propSchema.type })} |
|
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`} |
|
|
/> |
|
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
if (!jsonSchema.properties) { |
|
|
return ( |
|
|
<div className="p-4 bg-gray-50 rounded-md"> |
|
|
<p className="text-sm text-gray-600">{t('tool.noParameters')}</p> |
|
|
<div className="flex justify-end space-x-2 mt-4"> |
|
|
<button |
|
|
type="button" |
|
|
onClick={onCancel} |
|
|
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" |
|
|
> |
|
|
{t('tool.cancel')} |
|
|
</button> |
|
|
<button |
|
|
onClick={() => onSubmit({})} |
|
|
disabled={loading} |
|
|
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50" |
|
|
> |
|
|
{loading ? t('tool.running') : t('tool.runTool')} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
return ( |
|
|
<form onSubmit={handleSubmit} className="space-y-4"> |
|
|
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) => |
|
|
renderField(key, propSchema) |
|
|
)} |
|
|
|
|
|
<div className="flex justify-end space-x-2 pt-4"> |
|
|
<button |
|
|
type="button" |
|
|
onClick={onCancel} |
|
|
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" |
|
|
> |
|
|
{t('tool.cancel')} |
|
|
</button> |
|
|
<button |
|
|
type="submit" |
|
|
disabled={loading} |
|
|
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50" |
|
|
> |
|
|
{loading ? t('tool.running') : t('tool.runTool')} |
|
|
</button> |
|
|
</div> |
|
|
</form> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default DynamicForm; |
|
|
|