bake canonical build harness (deterministic /ship for forks) [developer-tools]
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataForm — pre-built form component wired to AnyDB insertOne
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRE-INSTALLED. CodeWriterAgent just uses:
|
||||
// import { DataForm } from "@/components/DataForm"
|
||||
// <DataForm
|
||||
// collection="contact_messages"
|
||||
// fields={[
|
||||
// { name: 'name', label: 'Full Name', required: true },
|
||||
// { name: 'email', label: 'Email', type: 'email', required: true },
|
||||
// { name: 'message', label: 'Message', type: 'textarea', rows: 4 },
|
||||
// ]}
|
||||
// submitLabel="Send Message"
|
||||
// successMessage="Message sent! We'll get back to you soon."
|
||||
// />
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@/hooks/use-anydb";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Loader2, CheckCircle } from "lucide-react";
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: "text" | "email" | "tel" | "number" | "date" | "time" | "url" | "textarea" | "select";
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: { value: string; label: string }[]; // For select type
|
||||
rows?: number; // For textarea
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: string | number;
|
||||
className?: string;
|
||||
gridCol?: number; // 1 or 2 — for grid layout
|
||||
}
|
||||
|
||||
interface DataFormProps {
|
||||
collection: string;
|
||||
fields: FormField[];
|
||||
submitLabel?: string;
|
||||
successMessage?: string;
|
||||
onSuccess?: (data: any) => void;
|
||||
className?: string;
|
||||
layout?: "stack" | "grid"; // stack = vertical, grid = 2-col
|
||||
}
|
||||
|
||||
export function DataForm({
|
||||
collection,
|
||||
fields,
|
||||
submitLabel = "Submit",
|
||||
successMessage = "Saved successfully!",
|
||||
onSuccess,
|
||||
className = "",
|
||||
layout = "stack",
|
||||
}: DataFormProps) {
|
||||
const { toast } = useToast();
|
||||
const { mutate, loading } = useMutation(collection);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
// Build initial form state from fields
|
||||
const initialState: Record<string, any> = {};
|
||||
fields.forEach(f => { initialState[f.name] = f.defaultValue || ""; });
|
||||
const [formData, setFormData] = useState(initialState);
|
||||
|
||||
const handleChange = (name: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation
|
||||
for (const field of fields) {
|
||||
if (field.required && !formData[field.name]) {
|
||||
toast({
|
||||
title: "Required field",
|
||||
description: `Please fill in ${field.label}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (field.type === "email" && formData[field.name] && !formData[field.name].includes("@")) {
|
||||
toast({
|
||||
title: "Invalid email",
|
||||
description: "Please enter a valid email address",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mutate(formData);
|
||||
setSubmitted(true);
|
||||
toast({ title: "Success", description: successMessage });
|
||||
setFormData(initialState);
|
||||
onSuccess?.(result);
|
||||
|
||||
// Reset submitted state after 3 seconds
|
||||
setTimeout(() => setSubmitted(false), 3000);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
const id = `form-${collection}-${field.name}`;
|
||||
|
||||
return (
|
||||
<div key={field.name} className={field.gridCol === 2 ? "col-span-2" : ""}>
|
||||
<Label htmlFor={id} className="text-sm font-medium mb-1.5 block">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
id={id}
|
||||
placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}...`}
|
||||
value={formData[field.name]}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
rows={field.rows || 4}
|
||||
className="resize-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
required={field.required}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
id={id}
|
||||
value={formData[field.name]}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
className="flex h-12 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
required={field.required}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">{field.placeholder || `Select ${field.label.toLowerCase()}`}</option>
|
||||
{field.options?.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
type={field.type || "text"}
|
||||
placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`}
|
||||
value={formData[field.name]}
|
||||
onChange={(e) => handleChange(field.name, field.type === "number" ? Number(e.target.value) : e.target.value)}
|
||||
className="h-12 focus-visible:ring-2 focus-visible:ring-primary"
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className} aria-busy={loading}>
|
||||
<div className={layout === "grid" ? "grid grid-cols-2 gap-4" : "space-y-4"}>
|
||||
{fields.map(renderField)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base mt-6 transition-all duration-150 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : submitted ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Sent!
|
||||
</>
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user