312 lines
No EOL
9.6 KiB
TypeScript
312 lines
No EOL
9.6 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import styles from './EditForm.module.css';
|
|
|
|
// Field configuration interface (moved from EditPopup)
|
|
export interface EditFieldConfig {
|
|
key: string;
|
|
label: string;
|
|
type: 'string' | 'email' | 'date' | 'enum' | 'boolean' | 'readonly' | 'textarea';
|
|
editable: boolean;
|
|
required?: boolean;
|
|
options?: string[]; // For enum types
|
|
formatter?: (value: any) => string; // For display formatting
|
|
validator?: (value: any) => string | null; // Returns error message or null
|
|
placeholder?: string;
|
|
minRows?: number; // For textarea types
|
|
maxRows?: number; // For textarea types
|
|
}
|
|
|
|
// EditForm props
|
|
export interface EditFormProps<T = any> {
|
|
data: T;
|
|
fields: EditFieldConfig[];
|
|
onSave: (updatedData: T) => void;
|
|
onCancel?: () => void;
|
|
saveButtonText?: string;
|
|
cancelButtonText?: string;
|
|
showButtons?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
// EditForm component - handles form logic
|
|
export function EditForm<T extends Record<string, any>>({
|
|
data,
|
|
fields,
|
|
onSave,
|
|
onCancel,
|
|
saveButtonText = 'Save',
|
|
cancelButtonText = 'Cancel',
|
|
showButtons = true,
|
|
className = ''
|
|
}: EditFormProps<T>) {
|
|
const [editedData, setEditedData] = useState<T>(data);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [fieldFocused, setFieldFocused] = useState<Record<string, boolean>>({});
|
|
|
|
// Reset data when data changes
|
|
useEffect(() => {
|
|
setEditedData({ ...data });
|
|
setErrors({});
|
|
setFieldFocused({});
|
|
|
|
// Initialize textarea heights for textarea fields
|
|
setTimeout(() => {
|
|
fields.forEach(field => {
|
|
if (field.type === 'textarea') {
|
|
const textarea = document.querySelector(`textarea[name="${field.key}"]`) as HTMLTextAreaElement;
|
|
if (textarea) {
|
|
const minRows = field.minRows || 4;
|
|
const maxRows = field.maxRows || 8;
|
|
textarea.style.height = 'auto';
|
|
const newHeight = Math.max(
|
|
minRows * 1.5 * 16,
|
|
Math.min(
|
|
textarea.scrollHeight,
|
|
maxRows * 1.5 * 16
|
|
)
|
|
);
|
|
textarea.style.height = `${newHeight}px`;
|
|
}
|
|
}
|
|
});
|
|
}, 0);
|
|
}, [data, fields]);
|
|
|
|
// Handle field focus
|
|
const handleFieldFocus = (fieldKey: string, focused: boolean) => {
|
|
setFieldFocused(prev => ({
|
|
...prev,
|
|
[fieldKey]: focused
|
|
}));
|
|
};
|
|
|
|
// Handle field value changes
|
|
const handleFieldChange = (fieldKey: string, value: any) => {
|
|
setEditedData(prev => ({
|
|
...prev,
|
|
[fieldKey]: value
|
|
}));
|
|
|
|
// Clear error for this field when user starts typing
|
|
if (errors[fieldKey]) {
|
|
setErrors(prev => {
|
|
const newErrors = { ...prev };
|
|
delete newErrors[fieldKey];
|
|
return newErrors;
|
|
});
|
|
}
|
|
};
|
|
|
|
// Validate all fields
|
|
const validateFields = (): boolean => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
fields.forEach(field => {
|
|
const value = editedData[field.key];
|
|
|
|
// Check required fields
|
|
if (field.required && (!value || value.toString().trim() === '')) {
|
|
newErrors[field.key] = `${field.label} is required`;
|
|
return;
|
|
}
|
|
|
|
// Run custom validator
|
|
if (field.validator && value) {
|
|
const error = field.validator(value);
|
|
if (error) {
|
|
newErrors[field.key] = error;
|
|
}
|
|
}
|
|
|
|
// Basic email validation
|
|
if (field.type === 'email' && value && !/\S+@\S+\.\S+/.test(value)) {
|
|
newErrors[field.key] = 'Invalid email format';
|
|
}
|
|
});
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// Handle save
|
|
const handleSave = () => {
|
|
if (validateFields()) {
|
|
onSave(editedData);
|
|
}
|
|
};
|
|
|
|
// Handle cancel
|
|
const handleCancel = () => {
|
|
setEditedData({ ...data });
|
|
setErrors({});
|
|
onCancel?.();
|
|
};
|
|
|
|
// Helper function to get label class
|
|
const getLabelClass = (fieldKey: string, value: any) => {
|
|
const isFocused = fieldFocused[fieldKey];
|
|
const hasValue = value && value.toString().trim() !== '';
|
|
|
|
if (isFocused) {
|
|
return styles.activeFocusedLabel; // Secondary color when actively focused
|
|
} else if (hasValue) {
|
|
return styles.focusedLabel; // Primary color when has value but not focused
|
|
} else {
|
|
return styles.label; // Regular position when empty and not focused
|
|
}
|
|
};
|
|
|
|
// Render field based on its type
|
|
const renderField = (field: EditFieldConfig) => {
|
|
const value = editedData[field.key];
|
|
const hasError = errors[field.key];
|
|
|
|
if (field.type === 'readonly' || !field.editable) {
|
|
return (
|
|
<div className={styles.floatingLabelInput} key={field.key}>
|
|
<div className={styles.readonlyField}>
|
|
{field.formatter ? field.formatter(value) : (value || 'N/A')}
|
|
</div>
|
|
<label className={styles.focusedLabel}>
|
|
{field.label}
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (field.type === 'enum') {
|
|
return (
|
|
<div className={styles.floatingLabelInput} key={field.key}>
|
|
<select
|
|
value={value || ''}
|
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
|
onFocus={() => handleFieldFocus(field.key, true)}
|
|
onBlur={() => handleFieldFocus(field.key, false)}
|
|
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
|
|
>
|
|
<option value="" disabled hidden></option>
|
|
{field.options?.map(option => (
|
|
<option key={option} value={option}>{option}</option>
|
|
))}
|
|
</select>
|
|
<label className={getLabelClass(field.key, value)}>
|
|
{field.label}
|
|
{field.required && <span className={styles.required}>*</span>}
|
|
</label>
|
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (field.type === 'boolean') {
|
|
return (
|
|
<div className={styles.fieldGroup} key={field.key}>
|
|
<label className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!value}
|
|
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
|
onFocus={() => handleFieldFocus(field.key, true)}
|
|
onBlur={() => handleFieldFocus(field.key, false)}
|
|
className={styles.checkboxInput}
|
|
/>
|
|
{field.label}
|
|
{field.required && <span className={styles.required}>*</span>}
|
|
</label>
|
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Handle textarea type
|
|
if (field.type === 'textarea') {
|
|
const minRows = field.minRows || 4;
|
|
const maxRows = field.maxRows || 8;
|
|
|
|
return (
|
|
<div className={styles.floatingLabelInput} key={field.key}>
|
|
<textarea
|
|
name={field.key}
|
|
value={value || ''}
|
|
onChange={(e) => {
|
|
handleFieldChange(field.key, e.target.value);
|
|
// Auto-resize textarea
|
|
const textarea = e.target;
|
|
textarea.style.height = 'auto';
|
|
const newHeight = Math.max(
|
|
minRows * 1.5 * 16, // minRows * line-height * font-size
|
|
Math.min(
|
|
textarea.scrollHeight,
|
|
maxRows * 1.5 * 16 // maxRows * line-height * font-size
|
|
)
|
|
);
|
|
textarea.style.height = `${newHeight}px`;
|
|
}}
|
|
onFocus={() => handleFieldFocus(field.key, true)}
|
|
onBlur={() => handleFieldFocus(field.key, false)}
|
|
className={`${styles.fieldTextarea} ${hasError ? styles.fieldError : ''}`}
|
|
rows={minRows}
|
|
/>
|
|
<label className={getLabelClass(field.key, value)}>
|
|
{field.label}
|
|
{field.required && <span className={styles.required}>*</span>}
|
|
</label>
|
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default to text input for string, email, date types
|
|
const inputType = field.type === 'email' ? 'email' :
|
|
field.type === 'date' ? 'date' : 'text';
|
|
|
|
return (
|
|
<div className={styles.floatingLabelInput} key={field.key}>
|
|
<input
|
|
type={inputType}
|
|
value={value || ''}
|
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
|
onFocus={() => handleFieldFocus(field.key, true)}
|
|
onBlur={() => handleFieldFocus(field.key, false)}
|
|
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
|
|
/>
|
|
<label className={getLabelClass(field.key, value)}>
|
|
{field.label}
|
|
{field.required && <span className={styles.required}>*</span>}
|
|
</label>
|
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={`${styles.editForm} ${className}`}>
|
|
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
|
{fields.map(field => renderField(field))}
|
|
</form>
|
|
|
|
{showButtons && (
|
|
<div className={styles.buttonGroup}>
|
|
{onCancel && (
|
|
<button
|
|
type="button"
|
|
className={styles.cancelButton}
|
|
onClick={handleCancel}
|
|
>
|
|
{cancelButtonText}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.saveButton}
|
|
onClick={handleSave}
|
|
>
|
|
{saveButtonText}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default EditForm; |