225 lines
9.6 KiB
TypeScript
225 lines
9.6 KiB
TypeScript
import { ReactNode } from 'react';
|
|
import { Modal } from '../ui/modal';
|
|
import Button from '../ui/button/Button';
|
|
import SelectDropdown from '../form/SelectDropdown';
|
|
import Label from '../form/Label';
|
|
|
|
export interface FormField {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'number' | 'email' | 'password' | 'select' | 'textarea';
|
|
placeholder?: string;
|
|
options?: Array<{ value: string; label: string }>;
|
|
value: any;
|
|
onChange: (value: any) => void;
|
|
required?: boolean;
|
|
min?: number;
|
|
max?: number;
|
|
rows?: number;
|
|
className?: string;
|
|
}
|
|
|
|
interface FormModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: () => void;
|
|
title: string;
|
|
fields?: FormField[];
|
|
submitLabel?: string;
|
|
cancelLabel?: string;
|
|
isLoading?: boolean;
|
|
className?: string;
|
|
customFooter?: ReactNode;
|
|
customBody?: ReactNode; // Custom body content that replaces fields
|
|
}
|
|
|
|
export default function FormModal({
|
|
isOpen,
|
|
onClose,
|
|
onSubmit,
|
|
title,
|
|
fields = [],
|
|
submitLabel = 'Create',
|
|
cancelLabel = 'Cancel',
|
|
isLoading = false,
|
|
className = 'max-w-2xl',
|
|
customFooter,
|
|
customBody,
|
|
}: FormModalProps) {
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
className={className}
|
|
>
|
|
<div className="p-6">
|
|
<h3 className="text-lg font-semibold mb-6 text-gray-800 dark:text-white">
|
|
{title}
|
|
</h3>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
onSubmit();
|
|
}}
|
|
className="space-y-4"
|
|
>
|
|
{customBody ? (
|
|
customBody
|
|
) : (
|
|
<>
|
|
{fields.find(f => f.key === 'keyword') && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{fields.find(f => f.key === 'keyword')!.label}
|
|
{fields.find(f => f.key === 'keyword')!.required && <span className="text-error-500 ml-1">*</span>}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
value={fields.find(f => f.key === 'keyword')!.value || ''}
|
|
onChange={(e) => fields.find(f => f.key === 'keyword')!.onChange(e.target.value)}
|
|
placeholder={fields.find(f => f.key === 'keyword')!.placeholder}
|
|
required={fields.find(f => f.key === 'keyword')!.required}
|
|
/>
|
|
</div>
|
|
)}
|
|
{(fields.find(f => f.key === 'volume') || fields.find(f => f.key === 'difficulty')) && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{fields.find(f => f.key === 'volume') && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{fields.find(f => f.key === 'volume')!.label}
|
|
{fields.find(f => f.key === 'volume')!.required && <span className="text-error-500 ml-1">*</span>}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
value={fields.find(f => f.key === 'volume')!.value || ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value === '' ? '' : parseInt(e.target.value) || 0;
|
|
fields.find(f => f.key === 'volume')!.onChange(value);
|
|
}}
|
|
placeholder={fields.find(f => f.key === 'volume')!.placeholder}
|
|
required={fields.find(f => f.key === 'volume')!.required}
|
|
/>
|
|
</div>
|
|
)}
|
|
{fields.find(f => f.key === 'difficulty') && (() => {
|
|
const difficultyField = fields.find(f => f.key === 'difficulty')!;
|
|
return (
|
|
<div>
|
|
<Label className="mb-2">
|
|
{difficultyField.label}
|
|
{difficultyField.required && <span className="text-error-500 ml-1">*</span>}
|
|
</Label>
|
|
{difficultyField.type === 'select' ? (
|
|
<SelectDropdown
|
|
options={difficultyField.options || []}
|
|
placeholder={difficultyField.placeholder || difficultyField.label}
|
|
value={difficultyField.value || ''}
|
|
onChange={(value) => difficultyField.onChange(value)}
|
|
className="w-full"
|
|
/>
|
|
) : (
|
|
<input
|
|
type="number"
|
|
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
value={difficultyField.value || ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value === '' ? '' : parseInt(e.target.value) || 0;
|
|
difficultyField.onChange(value);
|
|
}}
|
|
placeholder={difficultyField.placeholder}
|
|
required={difficultyField.required}
|
|
min={difficultyField.min}
|
|
max={difficultyField.max}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
{fields.filter(f => f.key !== 'keyword' && f.key !== 'volume' && f.key !== 'difficulty').map((field) => {
|
|
if (field.type === 'select') {
|
|
return (
|
|
<div key={field.key}>
|
|
<Label className="mb-2">
|
|
{field.label}
|
|
{field.required && <span className="text-error-500 ml-1">*</span>}
|
|
</Label>
|
|
<SelectDropdown
|
|
options={field.options || []}
|
|
placeholder={field.placeholder || field.label}
|
|
value={field.value || ''}
|
|
onChange={(value) => field.onChange(value)}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (field.type === 'textarea') {
|
|
return (
|
|
<div key={field.key}>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{field.label}
|
|
{field.required && <span className="text-error-500 ml-1">*</span>}
|
|
</label>
|
|
<textarea
|
|
rows={field.rows || 4}
|
|
className="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
value={field.value || ''}
|
|
onChange={(e) => field.onChange(e.target.value)}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div key={field.key}>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{field.label}
|
|
{field.required && <span className="text-error-500 ml-1">*</span>}
|
|
</label>
|
|
<input
|
|
type={field.type}
|
|
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
value={field.value || ''}
|
|
onChange={(e) => field.onChange(e.target.value)}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
min={field.min}
|
|
max={field.max}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
{customFooter || (
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={isLoading}
|
|
>
|
|
{cancelLabel}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? 'Processing...' : submitLabel}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|