v0.2.2 — Fix add plant form (simplify, remove react-hook-form)
This commit is contained in:
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { PlantCategory } from "@prisma/client";
|
import { PlantCategory } from "@prisma/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -20,91 +17,108 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { Plus, Leaf } from "lucide-react";
|
import { Plus, Leaf } from "lucide-react";
|
||||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||||
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
|
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const schema = z.object({
|
const BLANK = {
|
||||||
commonName: z.string().min(1, "Name is required"),
|
commonName: "",
|
||||||
species: z.string().optional(),
|
species: "",
|
||||||
variety: z.string().optional(),
|
variety: "",
|
||||||
category: z.nativeEnum(PlantCategory),
|
category: "CANOPY_TREE" as PlantCategory,
|
||||||
zone: z.string().optional(),
|
zone: "",
|
||||||
plantedAt: z.string().optional(),
|
plantedAt: "",
|
||||||
source: z.string().optional(),
|
source: "",
|
||||||
notes: z.string().optional(),
|
notes: "",
|
||||||
});
|
};
|
||||||
type FormValues = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
export function AddPlantButton() {
|
export function AddPlantButton() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [fields, setFields] = useState(BLANK);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const [suggestions, setSuggestions] = useState<PlantSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<PlantSuggestion[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [category, setCategory] = useState<PlantCategory>("CANOPY_TREE");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
function set(key: keyof typeof BLANK, value: string) {
|
||||||
resolver: zodResolver(schema),
|
setFields((f) => ({ ...f, [key]: value }));
|
||||||
defaultValues: { category: "CANOPY_TREE" },
|
}
|
||||||
});
|
|
||||||
|
|
||||||
function onNameChange(value: string) {
|
function onNameChange(value: string) {
|
||||||
form.setValue("commonName", value);
|
set("commonName", value);
|
||||||
const results = searchPlants(value);
|
const results = searchPlants(value);
|
||||||
setSuggestions(results);
|
setSuggestions(results);
|
||||||
setShowSuggestions(results.length > 0);
|
setShowSuggestions(results.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySuggestion(suggestion: PlantSuggestion) {
|
function applySuggestion(s: PlantSuggestion) {
|
||||||
form.setValue("commonName", suggestion.commonName);
|
setFields((f) => ({
|
||||||
if (suggestion.species) form.setValue("species", suggestion.species);
|
...f,
|
||||||
if (suggestion.notes && !form.getValues("notes")) form.setValue("notes", suggestion.notes);
|
commonName: s.commonName,
|
||||||
form.setValue("category", suggestion.category);
|
species: s.species ?? f.species,
|
||||||
setCategory(suggestion.category);
|
category: s.category,
|
||||||
|
notes: f.notes || s.notes || "",
|
||||||
|
}));
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
function handleClose() {
|
||||||
|
setOpen(false);
|
||||||
|
setFields(BLANK);
|
||||||
|
setError("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!fields.commonName.trim()) {
|
||||||
|
setError("Please enter the plant name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
const res = await fetch("/api/plants", {
|
const res = await fetch("/api/plants", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify({
|
||||||
|
commonName: fields.commonName.trim(),
|
||||||
|
species: fields.species.trim() || undefined,
|
||||||
|
variety: fields.variety.trim() || undefined,
|
||||||
|
category: fields.category,
|
||||||
|
zone: fields.zone.trim() || undefined,
|
||||||
|
plantedAt: fields.plantedAt || undefined,
|
||||||
|
source: fields.source.trim() || undefined,
|
||||||
|
notes: fields.notes.trim() || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
setBusy(false);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
toast({ title: "Error", description: err.error ?? "Could not save plant.", variant: "destructive" });
|
toast({ title: "Error", description: err.error ?? "Could not save plant.", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const plant = await res.json();
|
const plant = await res.json();
|
||||||
toast({ title: "Plant added!", description: `${values.commonName} is now in your food forest.` });
|
toast({ title: "Plant added!", description: `${fields.commonName} is now in your garden.` });
|
||||||
setOpen(false);
|
handleClose();
|
||||||
form.reset();
|
|
||||||
setCategory("CANOPY_TREE");
|
|
||||||
setSuggestions([]);
|
|
||||||
router.push(`/garden/${plant.id}`);
|
router.push(`/garden/${plant.id}`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpen() {
|
|
||||||
setOpen(true);
|
|
||||||
setSuggestions([]);
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={handleOpen} size="sm">
|
<Button onClick={() => setOpen(true)} size="sm">
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add plant
|
Add plant
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add a plant</DialogTitle>
|
<DialogTitle>Add a plant</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|
||||||
{/* Name with autocomplete */}
|
{/* Name with autocomplete */}
|
||||||
@@ -114,17 +128,13 @@ export function AddPlantButton() {
|
|||||||
id="commonName"
|
id="commonName"
|
||||||
placeholder="Start typing — apple, comfrey, zinnia…"
|
placeholder="Start typing — apple, comfrey, zinnia…"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
{...form.register("commonName", {
|
value={fields.commonName}
|
||||||
onChange: (e) => onNameChange(e.target.value),
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
})}
|
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.commonName && (
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
<p className="text-xs text-destructive">{form.formState.errors.commonName.message}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggestion dropdown */}
|
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border rounded-md shadow-lg overflow-hidden">
|
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border rounded-md shadow-lg overflow-hidden">
|
||||||
{suggestions.map((s) => (
|
{suggestions.map((s) => (
|
||||||
@@ -138,7 +148,9 @@ export function AddPlantButton() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{s.commonName}</p>
|
<p className="text-sm font-medium">{s.commonName}</p>
|
||||||
{s.species && (
|
{s.species && (
|
||||||
<p className="text-xs text-muted-foreground italic truncate">{s.species} · {CATEGORY_LABELS[s.category]}</p>
|
<p className="text-xs text-muted-foreground italic truncate">
|
||||||
|
{s.species} · {CATEGORY_LABELS[s.category]}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -152,18 +164,19 @@ export function AddPlantButton() {
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="variety">Variety / cultivar</Label>
|
<Label htmlFor="variety">Variety / cultivar</Label>
|
||||||
<Input id="variety" placeholder="Honeycrisp, Bob Gordon…" {...form.register("variety")} />
|
<Input
|
||||||
|
id="variety"
|
||||||
|
placeholder="Honeycrisp, Bob Gordon…"
|
||||||
|
value={fields.variety}
|
||||||
|
onChange={(e) => set("variety", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Category *</Label>
|
<Label>Category *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={category}
|
value={fields.category}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => set("category", v)}
|
||||||
const c = v as PlantCategory;
|
|
||||||
form.setValue("category", c);
|
|
||||||
setCategory(c);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -177,35 +190,65 @@ export function AddPlantButton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label htmlFor="species">Scientific name <span className="text-muted-foreground font-normal">(filled in automatically if known)</span></Label>
|
<Label htmlFor="species">
|
||||||
<Input id="species" placeholder="Malus domestica" className="italic" {...form.register("species")} />
|
Scientific name{" "}
|
||||||
|
<span className="text-muted-foreground font-normal">(filled in automatically if known)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="species"
|
||||||
|
placeholder="Malus domestica"
|
||||||
|
className="italic"
|
||||||
|
value={fields.species}
|
||||||
|
onChange={(e) => set("species", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label htmlFor="zone">Location on your property</Label>
|
<Label htmlFor="zone">Location on your property</Label>
|
||||||
<Input id="zone" placeholder="Back yard north fence, Front guild, Raised bed #1…" {...form.register("zone")} />
|
<Input
|
||||||
|
id="zone"
|
||||||
|
placeholder="Back yard north fence, Front guild, Raised bed #1…"
|
||||||
|
value={fields.zone}
|
||||||
|
onChange={(e) => set("zone", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="plantedAt">Date planted</Label>
|
<Label htmlFor="plantedAt">Date planted</Label>
|
||||||
<Input id="plantedAt" type="date" {...form.register("plantedAt")} />
|
<Input
|
||||||
|
id="plantedAt"
|
||||||
|
type="date"
|
||||||
|
value={fields.plantedAt}
|
||||||
|
onChange={(e) => set("plantedAt", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="source">Where it came from</Label>
|
<Label htmlFor="source">Where it came from</Label>
|
||||||
<Input id="source" placeholder="Jung's Nursery, from seed…" {...form.register("source")} />
|
<Input
|
||||||
|
id="source"
|
||||||
|
placeholder="Jung's Nursery, from seed…"
|
||||||
|
value={fields.source}
|
||||||
|
onChange={(e) => set("source", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label htmlFor="notes">Notes</Label>
|
<Label htmlFor="notes">Notes</Label>
|
||||||
<Textarea id="notes" placeholder="Anything worth remembering about this plant…" rows={3} {...form.register("notes")} />
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
placeholder="Anything worth remembering about this plant…"
|
||||||
|
rows={3}
|
||||||
|
value={fields.notes}
|
||||||
|
onChange={(e) => set("notes", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
<Button type="submit" disabled={busy}>
|
||||||
{form.formState.isSubmitting ? "Saving…" : "Add plant"}
|
{busy ? "Saving…" : "Add plant"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.2.0";
|
export const APP_VERSION = "0.2.2";
|
||||||
|
|||||||
Reference in New Issue
Block a user