diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx new file mode 100644 index 0000000..093f20a --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ColonyActions } from "./colony-actions"; +import { fetchColonies, mergeColony } from "@/lib/colonies/api"; +import type { Colony } from "@/lib/colonies/types"; + +vi.mock("@/lib/colonies/api", () => ({ + fetchColonies: vi.fn(), + mergeColony: vi.fn(), + moveColony: vi.fn(), + splitColony: vi.fn(), + dissolveColony: vi.fn(), + reactivateColony: vi.fn(), +})); +vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn().mockResolvedValue([]) })); +vi.mock("@/lib/queens/api", () => ({ + fetchQueens: vi.fn().mockResolvedValue([]), + assignQueenColony: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +function colony(id: string, code: string): Colony { + return { + id, + code, + apiaryId: "apiary-1", + hiveType: null, + originText: null, + colonyType: "PRODUCTION_COLONY", + status: "ACTIVE", + strength: null, + broodStatus: null, + feedStatus: null, + healthStatus: null, + notes: null, + currentQueenId: null, + createdAt: "", + updatedAt: "", + deactivatedAt: null, + }; +} + +const target = colony("colony-1", "V-01"); +const other = colony("colony-2", "V-02"); + +function renderActions() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe("ColonyActions – merge", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue([target, other]); + vi.mocked(mergeColony).mockReset(); + vi.mocked(mergeColony).mockResolvedValue(target); + }); + + it("asks for confirmation in plain words before merging", async () => { + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Vereinigen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "V-02" })); + + await userEvent.click(within(dialog).getByRole("button", { name: "Vereinigen" })); + + expect( + await screen.findByText( + "Volk V-02 geht in Volk V-01 auf und wird beendet. Das lässt sich nicht rückgängig machen.", + ), + ).toBeInTheDocument(); + expect(mergeColony).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole("button", { name: "V-02 in V-01 aufgehen lassen" }), + ); + + expect(mergeColony).toHaveBeenCalledWith("colony-1", { + mergedColonyIds: ["colony-2"], + }); + }); + + it("does not merge when the confirmation is cancelled", async () => { + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Vereinigen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "V-02" })); + await userEvent.click(within(dialog).getByRole("button", { name: "Vereinigen" })); + await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" })); + + expect(mergeColony).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 44811ac..b4954b6 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -1,9 +1,8 @@ "use client"; -import { FormEvent, useState } from "react"; +import { FormEvent, ReactNode, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { ApiError } from "@/lib/auth/api-client"; import { dissolveColony, mergeColony, @@ -16,13 +15,15 @@ import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; import { assignQueenColony, fetchQueens } from "@/lib/queens/api"; +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 { ConfirmDialog } from "@/components/confirm-dialog"; +import { Field } from "@/components/form/field"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -36,10 +37,6 @@ import { SelectValue, } from "@/components/ui/select"; -function errorMessage(error: unknown, fallback: string): string { - return error instanceof ApiError ? error.message : fallback; -} - function invalidateColony( queryClient: ReturnType, colonyId: string, @@ -49,23 +46,49 @@ function invalidateColony( void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] }); } +/** A failed action explains itself right above the button that caused it. */ +function ActionFooter({ + error, + children, +}: { + error: string | null; + children: ReactNode; +}) { + return ( + + {error ? ( +

+ {error} +

+ ) : null} + {children} +
+ ); +} + function MoveDialog({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [apiaryId, setApiaryId] = useState(""); + const [error, setError] = useState(null); const { data: apiaries } = useQuery({ queryKey: ["apiaries", { status: "ACTIVE" }], queryFn: () => fetchApiaries({ status: "ACTIVE" }), }); + const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId); const mutation = useMutation({ mutationFn: () => moveColony(colony.id, { apiaryId }), onSuccess: () => { invalidateColony(queryClient, colony.id); + const target = targets.find((apiary) => apiary.id === apiaryId); + toast.success( + target ? `${colony.code} steht jetzt am Stand „${target.name}“` : "Volk umgesetzt", + ); setOpen(false); }, - onError: (error) => - toast.error(errorMessage(error, "Volk konnte nicht umgesetzt werden")), + onError: (mutationError) => + setError(errorMessage(mutationError, "Volk konnte nicht umgesetzt werden.")), }); return ( @@ -80,36 +103,40 @@ function MoveDialog({ colony }: { colony: Colony }) { Volk umsetzen + + {colony.code} zieht an einen anderen Bienenstand. Der Wechsel wird in der Historie + festgehalten. +
{ event.preventDefault(); + setError(null); mutation.mutate(); }} > -
- - setApiaryId(value ?? "")}> + + + + + {targets.map((apiary) => ( {apiary.name} ))} - - -
- - - +
@@ -121,18 +148,31 @@ function SplitDialog({ colony }: { colony: Colony }) { const [open, setOpen] = useState(false); const [code, setCode] = useState(""); const [colonyType, setColonyType] = useState("NUCLEUS_COLONY"); + const [codeError, setCodeError] = useState(null); + const [error, setError] = useState(null); const mutation = useMutation({ mutationFn: () => splitColony(colony.id, { code, colonyType }), onSuccess: () => { invalidateColony(queryClient, colony.id); void queryClient.invalidateQueries({ queryKey: ["colonies"] }); + toast.success(`Ableger ${code} angelegt`); setOpen(false); }, - onError: (error) => - toast.error(errorMessage(error, "Ableger konnte nicht angelegt werden")), + onError: (mutationError) => + setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")), }); + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + if (!code.trim()) { + setCodeError("Bitte einen Code für den Ableger angeben."); + return; + } + mutation.mutate(); + } + return ( Ableger bilden + + Aus {colony.code} entsteht ein neues Volk. {colony.code} bleibt bestehen. + -
{ - event.preventDefault(); - mutation.mutate(); - }} - > -
- - setCode(event.target.value)} - /> -
-
- - -
- - - +
@@ -195,6 +240,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [queenId, setQueenId] = useState(""); + const [error, setError] = useState(null); const { data: queens } = useQuery({ queryKey: ["queens"], queryFn: () => fetchQueens() }); const currentQueen = queens?.find((queen) => queen.id === colony.currentQueenId); @@ -205,8 +251,8 @@ function AssignQueenDialog({ colony }: { colony: Colony }) { void queryClient.invalidateQueries({ queryKey: ["queens"] }); setOpen(false); }, - onError: (error) => - toast.error(errorMessage(error, "Königin konnte nicht zugewiesen werden")), + onError: (mutationError) => + setError(errorMessage(mutationError, "Königin konnte nicht zugewiesen werden.")), }); return ( @@ -226,52 +272,66 @@ function AssignQueenDialog({ colony }: { colony: Colony }) { className="flex flex-col gap-4" onSubmit={(event: FormEvent) => { event.preventDefault(); + setError(null); mutation.mutate(); }} > -
- - -
- - - + ); } +/** + * Merging ends the other colony for good, so the button in the dialog does + * not merge yet - it asks once more, in plain words, which colony goes away. + */ function MergeDialog({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [mergedColonyId, setMergedColonyId] = useState(""); + const [error, setError] = useState(null); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); + const candidates = (colonies ?? []).filter( + (candidate) => candidate.id !== colony.id && candidate.status === "ACTIVE", + ); + const merged = candidates.find((candidate) => candidate.id === mergedColonyId); const mutation = useMutation({ mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }), onSuccess: () => { invalidateColony(queryClient, colony.id); void queryClient.invalidateQueries({ queryKey: ["colonies"] }); + toast.success( + merged ? `${merged.code} ist jetzt Teil von ${colony.code}` : "Völker vereinigt", + ); setOpen(false); }, - onError: (error) => - toast.error(errorMessage(error, "Völker konnten nicht vereinigt werden")), + onError: (mutationError) => + setError(errorMessage(mutationError, "Völker konnten nicht vereinigt werden.")), }); return ( @@ -286,40 +346,50 @@ function MergeDialog({ colony }: { colony: Colony }) { Völker vereinigen + + Das gewählte Volk geht in {colony.code} auf. + -
{ - event.preventDefault(); - mutation.mutate(); - }} - > -
- - { + setMergedColonyId(value ?? ""); + setError(null); + }} + > + + + + + {candidates.map((candidate) => ( + + {candidate.code} ))} - - -
- - - -
+ + + )} + + + { + setError(null); + mutation.mutate(); + }} + /> + +
); @@ -335,7 +405,8 @@ function DissolveButton({ colony }: { colony: Colony }) { description: "Das Volk kann jederzeit über „Reaktivieren“ wiederhergestellt werden.", }); }, - onError: () => toast.error("Volk konnte nicht aufgelöst werden"), + onError: (error) => + toast.error(errorMessage(error, "Volk konnte nicht aufgelöst werden.")), }); return ( @@ -359,12 +430,14 @@ function ReactivateButton({ colony }: { colony: Colony }) { invalidateColony(queryClient, colony.id); toast.success("Volk reaktiviert"); }, - onError: () => toast.error("Volk konnte nicht reaktiviert werden"), + onError: (error) => + toast.error(errorMessage(error, "Volk konnte nicht reaktiviert werden.")), }); return ( + ); +} + +interface DialogSubmitBarProps { + isPending: boolean; + error?: string | null; + label?: string; +} + +/** + * Save button pinned to the bottom of a scrolling dialog: however long the + * form, the one action that finishes it never scrolls out of sight. A server + * error appears right above it, where the eyes already are. + */ +export function DialogSubmitBar({ + isPending, + error, + label = "Speichern", +}: DialogSubmitBarProps) { + return ( +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ); +} + +export const DATE_REQUIRED_MESSAGE = "Bitte ein Datum angeben."; + +/** True when at least one of the optional values already holds something. */ +export function hasAnyValue(...values: (string | null | undefined)[]): boolean { + return values.some((value) => Boolean(value && value.trim())); +} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx index 7eee599..e5f1deb 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/feeding-edit-dialog.tsx @@ -1,15 +1,12 @@ "use client"; import { FormEvent, useState } from "react"; -import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { updateFeeding } from "@/lib/feedings/api"; import { Feeding, FeedType } from "@/lib/feedings/types"; import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; -import { Button } from "@/components/ui/button"; +import { errorMessage } from "@/lib/forms/error-message"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, @@ -19,6 +16,8 @@ import { SelectValue, } from "@/components/ui/select"; import { ColonySelectField } from "@/components/colony-select-field"; +import { Field } from "@/components/form/field"; +import { FormSection } from "@/components/form/form-section"; import { Dialog, DialogContent, @@ -26,6 +25,18 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { + DATE_REQUIRED_MESSAGE, + DialogSubmitBar, + EditTriggerButton, + hasAnyValue, +} from "./edit-dialog-parts"; + +interface FieldErrors { + date?: string; + amount?: string; + unit?: string; +} export function FeedingEditDialog({ feeding }: { feeding: Feeding }) { const queryClient = useQueryClient(); @@ -38,6 +49,8 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) { const [unit, setUnit] = useState(feeding.unit); const [batchNumber, setBatchNumber] = useState(feeding.batchNumber ?? ""); const [notes, setNotes] = useState(feeding.notes ?? ""); + const [fieldErrors, setFieldErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); const mutation = useMutation({ mutationFn: () => @@ -61,108 +74,150 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) { } setOpen(false); }, - onError: () => toast.error("Fütterung konnte nicht gespeichert werden"), + onError: (error) => + setSubmitError(errorMessage(error, "Fütterung konnte nicht gespeichert werden.")), }); function handleSubmit(event: FormEvent) { event.preventDefault(); + setSubmitError(null); + const errors: FieldErrors = { + date: date ? undefined : DATE_REQUIRED_MESSAGE, + amount: amount.trim() ? undefined : "Bitte die Menge angeben.", + unit: unit.trim() ? undefined : "Bitte die Einheit angeben, z. B. kg oder l.", + }; + setFieldErrors(errors); + if (errors.date || errors.amount || errors.unit) { + return; + } mutation.mutate(); } return ( - - - Bearbeiten - - } - /> + } /> Fütterung bearbeiten -
- -
- - setDate(event.target.value)} + + + -
-
- - -
-
-
- - setAmount(event.target.value)} - /> + + {({ id, describedBy, invalid }) => ( + { + setDate(event.target.value); + setFieldErrors((current) => ({ ...current, date: undefined })); + }} + /> + )} + + + + + + {({ id }) => ( + + )} + +
+ + {({ id, describedBy, invalid }) => ( + { + setAmount(event.target.value); + setFieldErrors((current) => ({ ...current, amount: undefined })); + }} + /> + )} + + + {({ id, describedBy, invalid }) => ( + { + setUnit(event.target.value); + setFieldErrors((current) => ({ ...current, unit: undefined })); + }} + /> + )} +
-
- - setUnit(event.target.value)} - /> -
-
-
- - setProduct(event.target.value)} - /> -
-
- - setBatchNumber(event.target.value)} - /> -
-
- -