diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts b/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts new file mode 100644 index 0000000..f5c59d3 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts @@ -0,0 +1,52 @@ +import type { ApiaryInput } from "@/lib/apiaries/types"; + +/** Everything the form holds, as plain values that survive a draft save. */ +export interface ApiaryFormValues { + name: string; + location: string; + latitude: string; + longitude: string; + address: string; + notes: string; +} + +export type ApiaryFormErrors = Partial>; + +export function initialApiaryValues(): ApiaryFormValues { + return { + name: "", + location: "", + latitude: "", + longitude: "", + address: "", + notes: "", + }; +} + +/** Required fields in form order, so the first error is the first field. */ +export function validateApiary(values: ApiaryFormValues): ApiaryFormErrors { + const errors: ApiaryFormErrors = {}; + if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben."; + return errors; +} + +/** Six decimals are about 10 cm - more precise than any phone GPS. */ +export function formatCoordinate(value: number): string { + return value.toFixed(6); +} + +const textOrUndefined = (value: string) => + value.trim() ? value.trim() : undefined; +const numberOrUndefined = (value: string) => + value.trim() ? Number(value) : undefined; + +export function toApiaryInput(values: ApiaryFormValues): ApiaryInput { + return { + name: values.name.trim(), + location: textOrUndefined(values.location), + latitude: numberOrUndefined(values.latitude), + longitude: numberOrUndefined(values.longitude), + address: textOrUndefined(values.address), + notes: textOrUndefined(values.notes), + }; +} diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx new file mode 100644 index 0000000..690c0f4 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import NewApiaryPage from "./page"; +import { createApiary } from "@/lib/apiaries/api"; + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => new URLSearchParams(), +})); +vi.mock("@/lib/apiaries/api", () => ({ createApiary: vi.fn() })); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +function mockGeolocation( + getCurrentPosition: Geolocation["getCurrentPosition"], +) { + Object.defineProperty(navigator, "geolocation", { + configurable: true, + value: { getCurrentPosition }, + }); +} + +describe("NewApiaryPage", () => { + beforeEach(() => { + window.localStorage.clear(); + push.mockReset(); + vi.mocked(createApiary).mockReset(); + }); + + afterEach(() => { + Reflect.deleteProperty(navigator, "geolocation"); + }); + + it("marks only the name as required", () => { + renderPage(); + expect(screen.getAllByText("Pflicht")).toHaveLength(1); + expect( + screen.getByRole("button", { name: /Notizen/ }), + ).toHaveAttribute("aria-expanded", "false"); + }); + + it("creates the apiary with the entered values", async () => { + vi.mocked(createApiary).mockResolvedValue({ id: "apiary-3" } as never); + renderPage(); + + await userEvent.type(screen.getByLabelText(/Name/), "Hausgarten"); + await userEvent.type(screen.getByLabelText(/Adresse/), "Dorfstraße 1"); + await userEvent.click( + screen.getByRole("button", { name: "Bienenstand anlegen" }), + ); + + expect(vi.mocked(createApiary).mock.calls[0][0]).toEqual({ + name: "Hausgarten", + location: undefined, + latitude: undefined, + longitude: undefined, + address: "Dorfstraße 1", + notes: undefined, + }); + expect(push).toHaveBeenCalledWith("/apiaries/apiary-3"); + }); + + it("shows an inline error and does not save without a name", async () => { + renderPage(); + await userEvent.click( + screen.getByRole("button", { name: "Bienenstand anlegen" }), + ); + + expect(screen.getByText("Bitte einen Namen eingeben.")).toBeInTheDocument(); + expect(screen.getByLabelText(/Name/)).toHaveFocus(); + expect(createApiary).not.toHaveBeenCalled(); + }); + + it("fills latitude and longitude from the device location", async () => { + mockGeolocation((success) => + success({ + coords: { latitude: 52.5200081, longitude: 13.4049541 }, + } as GeolocationPosition), + ); + vi.mocked(createApiary).mockResolvedValue({ id: "apiary-4" } as never); + renderPage(); + + await userEvent.click( + screen.getByRole("button", { name: /Meinen Standort übernehmen/ }), + ); + expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(52.520008); + expect(screen.getByLabelText(/Längengrad/)).toHaveValue(13.404954); + + await userEvent.type(screen.getByLabelText(/Name/), "Waldrand"); + await userEvent.click( + screen.getByRole("button", { name: "Bienenstand anlegen" }), + ); + expect(vi.mocked(createApiary).mock.calls[0][0]).toMatchObject({ + latitude: 52.520008, + longitude: 13.404954, + }); + }); + + it("explains inline when the location permission is denied", async () => { + mockGeolocation((_success, failure) => + failure?.({ code: 1 } as GeolocationPositionError), + ); + renderPage(); + + await userEvent.click( + screen.getByRole("button", { name: /Meinen Standort übernehmen/ }), + ); + expect(screen.getByRole("status")).toHaveTextContent( + /Standort-Freigabe abgelehnt/, + ); + expect(screen.getByLabelText(/Breitengrad/)).toHaveValue(null); + }); + + it("explains inline when the device has no location service", async () => { + renderPage(); + await userEvent.click( + screen.getByRole("button", { name: /Meinen Standort übernehmen/ }), + ); + expect(screen.getByRole("status")).toHaveTextContent(/nicht verfügbar/); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx index a8f1ece..ec15683 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx @@ -2,49 +2,120 @@ import { FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; -import { MapPin } from "lucide-react"; +import { LocateFixed, MapPin } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { createApiary } from "@/lib/apiaries/api"; +import { useFormDraft } from "@/lib/forms/use-form-draft"; +import { errorMessage } from "@/lib/forms/error-message"; 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 { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; +import { Field } from "@/components/form/field"; +import { FormSection } from "@/components/form/form-section"; +import { StickySubmit } from "@/components/form/sticky-submit"; +import { DraftBanner } from "@/components/form/draft-banner"; +import { + ApiaryFormErrors, + ApiaryFormValues, + formatCoordinate, + initialApiaryValues, + toApiaryInput, + validateApiary, +} from "./apiary-form-values"; + +const PERMISSION_DENIED = 1; +const LOCATE_TIMEOUT_MS = 15_000; export default function NewApiaryPage() { const router = useRouter(); const queryClient = useQueryClient(); - const [name, setName] = useState(""); - const [location, setLocation] = useState(""); - const [latitude, setLatitude] = useState(""); - const [longitude, setLongitude] = useState(""); - const [address, setAddress] = useState(""); - const [notes, setNotes] = useState(""); - const [error, setError] = useState(null); + const [values, setValues] = useState(initialApiaryValues); + const [dirty, setDirty] = useState(false); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + const [locating, setLocating] = useState(false); + const [locateMessage, setLocateMessage] = useState(null); + const draft = useFormDraft("apiary-new", values, dirty); + + function set( + key: K, + value: ApiaryFormValues[K], + ) { + setValues((current) => ({ ...current, [key]: value })); + setDirty(true); + setErrors((current) => (current[key] ? { ...current, [key]: undefined } : current)); + } + + function takeMyLocation() { + setLocateMessage(null); + if (typeof navigator === "undefined" || !navigator.geolocation) { + setLocateMessage( + "Standort ist auf diesem Gerät nicht verfügbar – bitte die Koordinaten selbst eintragen.", + ); + return; + } + setLocating(true); + navigator.geolocation.getCurrentPosition( + (position) => { + setLocating(false); + setValues((current) => ({ + ...current, + latitude: formatCoordinate(position.coords.latitude), + longitude: formatCoordinate(position.coords.longitude), + })); + setDirty(true); + }, + (positionError) => { + setLocating(false); + setLocateMessage( + positionError.code === PERMISSION_DENIED + ? "Standort-Freigabe abgelehnt – du kannst sie in den Browser-Einstellungen erlauben oder die Koordinaten selbst eintragen." + : "Standort konnte nicht ermittelt werden – bitte später erneut versuchen oder selbst eintragen.", + ); + }, + { enableHighAccuracy: true, timeout: LOCATE_TIMEOUT_MS }, + ); + } const mutation = useMutation({ mutationFn: createApiary, onSuccess: (apiary) => { + draft.clear(); void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); + toast.success("Bienenstand angelegt"); router.push(`/apiaries/${apiary.id}`); }, - onError: () => { - setError("Bienenstand konnte nicht angelegt werden."); + onError: (mutationError) => { + setSubmitError( + errorMessage(mutationError, "Bienenstand konnte nicht angelegt werden."), + ); }, }); function handleSubmit(event: FormEvent) { event.preventDefault(); - setError(null); - mutation.mutate({ - name, - location: location || undefined, - latitude: latitude ? Number(latitude) : undefined, - longitude: longitude ? Number(longitude) : undefined, - address: address || undefined, - notes: notes || undefined, - }); + setSubmitError(null); + const found = validateApiary(values); + setErrors(found); + const firstInvalid = Object.keys(found)[0]; + if (firstInvalid) { + event.currentTarget + .querySelector(`[data-field="${firstInvalid}"]`) + ?.focus(); + return; + } + mutation.mutate(toApiaryInput(values)); + } + + function restoreDraft() { + const restored = draft.restore(); + if (restored) { + setValues(restored); + setDirty(true); + } } return ( @@ -52,78 +123,152 @@ export default function NewApiaryPage() { + {draft.pending ? ( + + ) : null} -
-
- - setName(event.target.value)} - /> -
-
- - setLocation(event.target.value)} - /> -
-
-
- - setLatitude(event.target.value)} - /> + + + + {({ id, describedBy, invalid }) => ( + set("name", event.target.value)} + /> + )} + + + {({ id, describedBy }) => ( + set("location", event.target.value)} + /> + )} + + + + +
+ + {locateMessage ? ( +

+ {locateMessage} +

+ ) : null}
-
- - setLongitude(event.target.value)} - /> +
+ + {({ id, describedBy }) => ( + set("latitude", event.target.value)} + /> + )} + + + {({ id, describedBy }) => ( + set("longitude", event.target.value)} + /> + )} +
-
-
- - setAddress(event.target.value)} - /> -
-
- -