377 lines
12 KiB
TypeScript

/**
* DynamicField Component
*
* Renders form fields dynamically based on Frappe's field configuration.
* Supports conditional visibility, mandatory, read-only states, and various field types.
*/
import React, { useMemo } from 'react';
import { type FieldConfig, evaluateFieldState, parseSelectOptions, getInputType } from '../utils/frappeExpressionEvaluator';
import LinkField from './LinkField';
interface DynamicFieldProps {
fieldConfig: FieldConfig;
value: any;
onChange: (fieldname: string, value: any) => void;
doc: Record<string, any>;
disabled?: boolean;
compact?: boolean;
className?: string;
error?: string;
}
const DynamicField: React.FC<DynamicFieldProps> = ({
fieldConfig,
value,
onChange,
doc,
disabled = false,
compact = false,
className = '',
error
}) => {
// Evaluate field state based on current document
const fieldState = useMemo(() => {
return evaluateFieldState(fieldConfig, doc);
}, [fieldConfig, doc]);
// Don't render if field is not visible
if (!fieldState.isVisible) {
return null;
}
// Skip layout fields (Section Break, Column Break, Tab Break)
if (['Section Break', 'Column Break', 'Tab Break', 'HTML'].includes(fieldConfig.fieldtype)) {
return null;
}
const isDisabled = disabled || fieldState.isReadOnly;
const isRequired = fieldState.isMandatory;
const inputType = getInputType(fieldConfig.fieldtype);
const labelClasses = compact
? 'block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5'
: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
const inputClasses = compact
? `w-full px-2 py-1 text-xs border rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
isDisabled ? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed' : ''
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`
: `w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
isDisabled ? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed' : ''
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`;
const handleChange = (newValue: any) => {
onChange(fieldConfig.fieldname, newValue);
};
// Render based on field type
const renderField = () => {
switch (fieldConfig.fieldtype) {
case 'Link':
return (
<LinkField
label={fieldConfig.label || fieldConfig.fieldname}
doctype={fieldConfig.options || ''}
value={value || ''}
onChange={handleChange}
disabled={isDisabled}
compact={compact}
placeholder={`Select ${fieldConfig.label || fieldConfig.fieldname}`}
/>
);
case 'Select':
const options = parseSelectOptions(fieldConfig.options);
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<select
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
>
<option value="">Select...</option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{fieldConfig.description && !error && (
<p className="text-gray-500 dark:text-gray-400 text-xs mt-1">{fieldConfig.description}</p>
)}
</div>
);
case 'Check':
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
type="checkbox"
checked={value === 1 || value === true}
onChange={(e) => handleChange(e.target.checked ? 1 : 0)}
disabled={isDisabled}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label className="text-sm text-gray-700 dark:text-gray-300">
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
);
case 'Date':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="date"
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Datetime':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="datetime-local"
value={value ? value.replace(' ', 'T').substring(0, 16) : ''}
onChange={(e) => handleChange(e.target.value.replace('T', ' '))}
disabled={isDisabled}
className={inputClasses}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Int':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="number"
value={value ?? ''}
onChange={(e) => handleChange(e.target.value ? parseInt(e.target.value) : null)}
disabled={isDisabled}
className={inputClasses}
step="1"
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Float':
case 'Currency':
case 'Percent':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="number"
value={value ?? ''}
onChange={(e) => handleChange(e.target.value ? parseFloat(e.target.value) : null)}
disabled={isDisabled}
className={inputClasses}
step="0.01"
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Small Text':
case 'Text':
case 'Long Text':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<textarea
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
rows={fieldConfig.fieldtype === 'Long Text' ? 6 : 3}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Read Only':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
</label>
<div className={`${inputClasses} bg-gray-50 dark:bg-gray-800`}>
{value || '-'}
</div>
</div>
);
case 'Attach':
case 'Attach Image':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{value && (
<div className="mb-2">
{fieldConfig.fieldtype === 'Attach Image' && value ? (
<img src={value} alt={fieldConfig.label} className="w-24 h-24 object-cover rounded" />
) : (
<a href={value} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-sm">
{value.split('/').pop()}
</a>
)}
</div>
)}
<input
type="file"
onChange={(e) => {
// Handle file upload - you may need to implement actual upload logic
const file = e.target.files?.[0];
if (file) {
// For now, just store the file name
handleChange(file.name);
}
}}
disabled={isDisabled}
className={inputClasses}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Data':
case 'Password':
default:
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type={fieldConfig.fieldtype === 'Password' ? 'password' : 'text'}
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
placeholder={fieldConfig.description || ''}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
}
};
return renderField();
};
export default DynamicField;
/**
* DynamicForm Component
* Renders a complete form based on DocType field configuration
*/
interface DynamicFormProps {
fields: FieldConfig[];
doc: Record<string, any>;
onChange: (fieldname: string, value: any) => void;
errors?: Record<string, string>;
disabled?: boolean;
compact?: boolean;
columns?: 1 | 2 | 3 | 4;
excludeFields?: string[];
includeFields?: string[];
}
export const DynamicForm: React.FC<DynamicFormProps> = ({
fields,
doc,
onChange,
errors = {},
disabled = false,
compact = false,
columns = 2,
excludeFields = [],
includeFields
}) => {
// Filter and sort fields
const visibleFields = useMemo(() => {
let filtered = fields.filter(f => {
// Skip layout fields
if (['Section Break', 'Column Break', 'Tab Break'].includes(f.fieldtype)) {
return false;
}
// Apply exclude filter
if (excludeFields.includes(f.fieldname)) {
return false;
}
// Apply include filter if specified
if (includeFields && !includeFields.includes(f.fieldname)) {
return false;
}
// Check visibility
const state = evaluateFieldState(f, doc);
return state.isVisible;
});
return filtered;
}, [fields, doc, excludeFields, includeFields]);
const gridClass = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
}[columns];
return (
<div className={`grid ${gridClass} gap-4`}>
{visibleFields.map(field => (
<DynamicField
key={field.fieldname}
fieldConfig={field}
value={doc[field.fieldname]}
onChange={onChange}
doc={doc}
disabled={disabled}
compact={compact}
error={errors[field.fieldname]}
/>
))}
</div>
);
};