- 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
113 lines
3.3 KiB
TypeScript
113 lines
3.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|