377 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|
|
|