feat: init drizzle implementation for backend (express) and frontend (vite-react)
This commit is contained in:
parent
fc3a21263a
commit
aca6a4da98
17 changed files with 1608 additions and 35 deletions
344
packages/ui/src/components/template/create-form.tsx
Normal file
344
packages/ui/src/components/template/create-form.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../card";
|
||||
import { cn } from "@repo/ui/lib/utils";
|
||||
|
||||
// Base interface for form fields
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
type:
|
||||
| "text"
|
||||
| "number"
|
||||
| "email"
|
||||
| "password"
|
||||
| "textarea"
|
||||
| "url"
|
||||
| "tel"
|
||||
| "date"
|
||||
| "select"
|
||||
| "checkbox";
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: { value: string; label: string }[]; // For select fields
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
// Generic form data interface
|
||||
export interface FormData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CreateFormProps {
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
onSubmit: (data: FormData) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
loading?: boolean;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
className?: string;
|
||||
initialData?: FormData;
|
||||
}
|
||||
|
||||
export function CreateForm({
|
||||
title,
|
||||
fields,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
submitText = "Create",
|
||||
cancelText = "Cancel",
|
||||
className,
|
||||
initialData = {},
|
||||
}: CreateFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>(() => {
|
||||
const initial: FormData = {};
|
||||
fields.forEach((field) => {
|
||||
initial[field.key] =
|
||||
initialData[field.key] ||
|
||||
(field.type === "number"
|
||||
? 0
|
||||
: field.type === "checkbox"
|
||||
? false
|
||||
: "");
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const validateField = (field: FormField, value: any): string | null => {
|
||||
if (
|
||||
field.required &&
|
||||
(!value || (typeof value === "string" && !value.trim()))
|
||||
) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
|
||||
if (field.validation) {
|
||||
const { min, max, pattern, minLength, maxLength } =
|
||||
field.validation;
|
||||
|
||||
if (field.type === "number" && typeof value === "number") {
|
||||
if (min !== undefined && value < min) {
|
||||
return `${field.label} must be at least ${min}`;
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
return `${field.label} must be at most ${max}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (minLength !== undefined && value.length < minLength) {
|
||||
return `${field.label} must be at least ${minLength} characters`;
|
||||
}
|
||||
if (maxLength !== undefined && value.length > maxLength) {
|
||||
return `${field.label} must be at most ${maxLength} characters`;
|
||||
}
|
||||
if (pattern && !new RegExp(pattern).test(value)) {
|
||||
return `${field.label} format is invalid`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
const error = validateField(field, formData[field.key]);
|
||||
if (error) {
|
||||
newErrors[field.key] = error;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (key: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[key]) {
|
||||
setErrors((prev) => ({ ...prev, [key]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
|
||||
// Reset form on success
|
||||
const resetData: FormData = {};
|
||||
fields.forEach((field) => {
|
||||
resetData[field.key] =
|
||||
field.type === "number"
|
||||
? 0
|
||||
: field.type === "checkbox"
|
||||
? false
|
||||
: "";
|
||||
});
|
||||
setFormData(resetData);
|
||||
setErrors({});
|
||||
} catch (error: any) {
|
||||
setErrors({ submit: error.message || "An error occurred" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
const isFieldDisabled = loading || isSubmitting || field.disabled;
|
||||
const fieldError = errors[field.key];
|
||||
|
||||
const baseInputClasses = cn(
|
||||
"w-full mt-1 p-2 border rounded-md transition-colors",
|
||||
"focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||
fieldError ? "border-red-500" : "border-gray-300",
|
||||
isFieldDisabled && "opacity-50 cursor-not-allowed",
|
||||
field.className
|
||||
);
|
||||
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<textarea
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<select
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
>
|
||||
<option value="">
|
||||
{field.placeholder || `Select ${field.label}`}
|
||||
</option>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
checked={formData[field.key] || false}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.checked)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700">
|
||||
{field.placeholder || field.label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
field.key,
|
||||
parseFloat(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
placeholder={field.placeholder}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
step={field.type === "number" ? "any" : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type={field.type}
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
placeholder={field.placeholder}
|
||||
pattern={field.validation?.pattern}
|
||||
minLength={field.validation?.minLength}
|
||||
maxLength={field.validation?.maxLength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== "checkbox" && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{renderField(field)}
|
||||
|
||||
{errors[field.key] && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.helpText && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{field.helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errors.submit && (
|
||||
<div className="text-red-600 text-sm p-2 bg-red-50 rounded">
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : submitText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export type { CreateFormProps };
|
||||
Loading…
Add table
Add a link
Reference in a new issue