feat: garden containers crud (task 71)
- add listContainers/getContainer/searchContainers queries - add createContainer/updateContainer/deleteContainer server actions with logActivity - add ContainerList, ContainerDetail, ContainerForm client components - add /garden, /garden/containers/[id], /garden/containers/new pages - register garden.container entity with share + search in manifest - add sprout icon to NavIcon registry - add garden e2e test spec
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { createContainer, updateContainer } from "../server/actions";
|
||||
import type { ContainerDto } from "../server/queries";
|
||||
|
||||
const CONTAINER_TYPES = [
|
||||
{ value: "shelf", label: "Shelf" },
|
||||
{ value: "terrarium", label: "Terrarium" },
|
||||
{ value: "raised-bed", label: "Raised bed" },
|
||||
{ value: "window-box", label: "Window box" },
|
||||
{ value: "single-pot", label: "Single pot" },
|
||||
{ value: "outdoor", label: "Outdoor" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
existing?: ContainerDto;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export function ContainerForm({ existing, onSuccess, onCancel }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const input = {
|
||||
name: fd.get("name") as string,
|
||||
type: fd.get("type") as string,
|
||||
locationNotes: (fd.get("locationNotes") as string) || null,
|
||||
};
|
||||
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (existing) {
|
||||
await updateContainer({ id: existing.id, ...input });
|
||||
} else {
|
||||
await createContainer(input);
|
||||
}
|
||||
formRef.current?.reset();
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="container-name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="container-name"
|
||||
name="name"
|
||||
required
|
||||
maxLength={120}
|
||||
defaultValue={existing?.name}
|
||||
placeholder="e.g. Living Room Shelf"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="container-type" className="text-sm font-medium">
|
||||
Type
|
||||
</label>
|
||||
<select id="container-type" name="type" defaultValue={existing?.type ?? "other"}>
|
||||
{CONTAINER_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="container-notes" className="text-sm font-medium">
|
||||
Location notes
|
||||
</label>
|
||||
<textarea
|
||||
id="container-notes"
|
||||
name="locationNotes"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
defaultValue={existing?.locationNotes ?? ""}
|
||||
placeholder="Optional — e.g. south-facing window"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} className="btn btn-ghost" disabled={isPending}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? "Saving…" : existing ? "Save" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user