Completes the Location feature (schema + migration landed earlier under 98cabc5).
- Hierarchical Location model is the shared spine; unlimited nesting
- Garden add/edit now picks a reusable Location (inline create) instead of
freeform text; garden groups By Location off the real tree
- Dual-write keeps legacy Plant.zone in sync with location.name (Release A)
- /api/locations CRUD + /api/locations/[id]/log (logs a location + its sub-locations)
- Location tree pure-core (buildTree/computePath/descendantIds) with tests
- Location added to backup/restore export
- Bump APP_VERSION 0.10.0 + plain-English changelog entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
9.1 KiB
TypeScript
243 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import { Plant, PlantCategory } from "@prisma/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Pencil } from "lucide-react";
|
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
|
|
|
type LocationOption = { id: string; name: string };
|
|
|
|
const schema = z.object({
|
|
commonName: z.string().min(1, "Name is required"),
|
|
species: z.string().optional(),
|
|
variety: z.string().optional(),
|
|
category: z.nativeEnum(PlantCategory),
|
|
locationId: z.string().optional(),
|
|
plantedAt: z.string().optional(),
|
|
source: z.string().optional(),
|
|
notes: z.string().optional(),
|
|
});
|
|
type FormValues = z.infer<typeof schema>;
|
|
|
|
function toDateInput(d: Date | null): string {
|
|
if (!d) return "";
|
|
return new Date(d).toISOString().split("T")[0];
|
|
}
|
|
|
|
export function EditPlantButton({
|
|
plant,
|
|
locations = [],
|
|
groups = [],
|
|
}: {
|
|
plant: Plant;
|
|
locations?: LocationOption[];
|
|
groups?: { id: string; name: string }[];
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
|
|
const [creatingLocation, setCreatingLocation] = useState(false);
|
|
const [newLocationName, setNewLocationName] = useState("");
|
|
const router = useRouter();
|
|
const { toast } = useToast();
|
|
|
|
const form = useForm<FormValues>({
|
|
resolver: zodResolver(schema),
|
|
defaultValues: {
|
|
commonName: plant.commonName,
|
|
species: plant.species ?? "",
|
|
variety: plant.variety ?? "",
|
|
category: plant.category,
|
|
locationId: plant.locationId ?? "",
|
|
plantedAt: toDateInput(plant.plantedAt),
|
|
source: plant.source ?? "",
|
|
notes: plant.notes ?? "",
|
|
},
|
|
});
|
|
|
|
async function addNewLocation() {
|
|
const name = newLocationName.trim();
|
|
if (!name) return;
|
|
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
if (existing) {
|
|
form.setValue("locationId", existing.id);
|
|
setNewLocationName("");
|
|
setCreatingLocation(false);
|
|
return;
|
|
}
|
|
const res = await fetch("/api/locations", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
if (!res.ok) {
|
|
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
|
|
return;
|
|
}
|
|
const loc = await res.json();
|
|
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
|
|
form.setValue("locationId", loc.id);
|
|
setNewLocationName("");
|
|
setCreatingLocation(false);
|
|
}
|
|
|
|
async function onSubmit(values: FormValues) {
|
|
const res = await fetch(`/api/plants/${plant.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...values, locationId: values.locationId || null }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast({ title: "Error", description: err.error ?? "Could not save changes.", variant: "destructive" });
|
|
return;
|
|
}
|
|
toast({ title: "Saved!" });
|
|
setOpen(false);
|
|
router.refresh();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
<Pencil className="h-4 w-4 mr-1" />
|
|
Edit
|
|
</Button>
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit plant</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label htmlFor="edit-commonName">Common name *</Label>
|
|
<Input id="edit-commonName" {...form.register("commonName")} />
|
|
{form.formState.errors.commonName && (
|
|
<p className="text-xs text-destructive">{form.formState.errors.commonName.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="edit-variety">Variety / cultivar</Label>
|
|
<Input id="edit-variety" {...form.register("variety")} />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Category *</Label>
|
|
<Select
|
|
defaultValue={plant.category}
|
|
onValueChange={(v) => form.setValue("category", v as PlantCategory)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CATEGORY_ORDER.map((cat) => (
|
|
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label htmlFor="edit-species">Scientific name</Label>
|
|
<Input id="edit-species" className="italic" {...form.register("species")} />
|
|
</div>
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Location on your property</Label>
|
|
{!creatingLocation ? (
|
|
<Select
|
|
defaultValue={plant.locationId ?? ""}
|
|
onValueChange={(v) => {
|
|
if (v === "__new__") { setCreatingLocation(true); form.setValue("locationId", ""); }
|
|
else { form.setValue("locationId", v); setNewLocationName(""); }
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="No location" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">No location</SelectItem>
|
|
{locationOptions.map((loc) => (
|
|
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
|
|
))}
|
|
<SelectItem value="__new__">+ Add new location…</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
|
|
value={newLocationName}
|
|
onChange={(e) => setNewLocationName(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
|
|
autoFocus
|
|
/>
|
|
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingLocation(false); setNewLocationName(""); }}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="edit-plantedAt">Date planted</Label>
|
|
<Input id="edit-plantedAt" type="date" {...form.register("plantedAt")} />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Where did it come from?</Label>
|
|
<Select
|
|
defaultValue={plant.source ?? ""}
|
|
onValueChange={(v) => form.setValue("source", v)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select one…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="We planted it">We planted it</SelectItem>
|
|
<SelectItem value="Already here">Already here when we moved in</SelectItem>
|
|
<SelectItem value="Gift or trade">Gift or trade</SelectItem>
|
|
<SelectItem value="Self-seeded">Self-seeded</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label htmlFor="edit-notes">Notes</Label>
|
|
<Textarea id="edit-notes" rows={3} {...form.register("notes")} />
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
{form.formState.isSubmitting ? "Saving…" : "Save changes"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|