feat: Bearbeiten-Dialoge für Charge, Ernte, Abfüllung und Produkt mit dem Formular-Kit

Felder mit Pflicht-Markierung und Fehler direkt am Feld, Fokus auf das
erste Problemfeld, Serverfehler über dem Speichern-Knopf, Fokus nach
dem Schließen zurück auf „Bearbeiten“; Ernte-Völker als Kacheln;
Bearbeiten offline gesperrt wie bei den Volks-Dialogen (Änderungen
werden nicht vorgemerkt). Gemeinsame Teile DialogSubmitBar und
EditTriggerButton nach components/form.

Refs #9

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:20:09 +02:00
1 parent 1806255211
commit ced65314ba
16 files changed
+1222 -499

No files matched your search

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { EditTriggerButton } from "@/components/form/edit-dialog-parts";
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode }));
describe("EditTriggerButton", () => {
afterEach(() => {
sessionMode = "online";
reportRequestOutcome("reached-server");
});
it("is a normal „Bearbeiten“ button while online", () => {
render(<EditTriggerButton />);
expect(screen.getByRole("button", { name: "Bearbeiten" })).toBeEnabled();
});
it("is disabled offline and says why", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled();
});
it("is disabled in an offline session even when the network looks fine", () => {
sessionMode = "offline";
render(<EditTriggerButton />);
expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled();
});
it("stays disabled when the caller disables it, and keeps the caller's other props", () => {
render(<EditTriggerButton disabled data-testid="edit" />);
expect(screen.getByTestId("edit")).toBeDisabled();
expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten");
});
it("names the reason in visible text next to the offline button", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByText("Nur mit Verbindung")).toBeVisible();
});
it("is at least 44 px high", () => {
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("min-h-11");
});
it("keeps its label on one line so it never crowds the heading next to it", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("shrink-0", "whitespace-nowrap");
expect(screen.getByRole("button")).not.toHaveClass("whitespace-normal");
});
it("lets the offline button and its hint wrap inside the card instead of overflowing it", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
const wrapper = screen.getByText("Nur mit Verbindung").parentElement;
expect(wrapper).toHaveClass("ml-auto", "max-w-full", "flex-wrap");
expect(wrapper).not.toHaveClass("shrink-0");
});
});
@@ -0,0 +1,88 @@
"use client";
import { ComponentProps } from "react";
import { Pencil, WifiOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSessionMode } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
import { cn } from "@/lib/utils";
/**
* The "Bearbeiten" trigger of every record in a list: at least 40 px high on
* a phone, so it can be hit with a gloved thumb. Saved records can only be
* changed with a connection (edits are not queued), so offline - or in an
* offline session without a token - it is disabled and says why: a "no
* network" icon and a tooltip for the eye, the full sentence for screen
* readers. The visible label stays one short word so it never pushes the
* heading of its row out of the way on a phone.
*/
export function EditTriggerButton({
disabled,
className,
...props
}: ComponentProps<typeof Button>) {
const connectivity = useConnectivity();
const sessionMode = useSessionMode();
const needsNetwork = connectivity === "offline" || sessionMode === "offline";
const button = (
<Button
variant="ghost"
{...props}
disabled={disabled || needsNetwork}
title={needsNetwork ? "Bearbeiten braucht Netz" : props.title}
aria-label={needsNetwork ? "Bearbeiten braucht Netz" : props["aria-label"]}
className={cn("min-h-11", needsNetwork && "shrink-0 whitespace-nowrap", className)}
>
{needsNetwork ? (
<WifiOff className="size-4" aria-hidden />
) : (
<Pencil className="size-4" aria-hidden />
)}
Bearbeiten
</Button>
);
if (!needsNetwork) return button;
return (
<span className="ml-auto inline-flex max-w-full flex-wrap items-center justify-end gap-x-2">
{button}
<span className="text-xs text-muted-foreground">Nur mit Verbindung</span>
</span>
);
}
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 (
<div className="sticky bottom-0 z-10 -mx-5 -mb-5 flex flex-col gap-2 rounded-b-2xl border-t border-border/60 bg-popover px-5 py-3">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="touch" disabled={isPending}>
{isPending ? "Wird gespeichert…" : label}
</Button>
</div>
);
}
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()));
}