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:
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()));
|
||||
}
|
||||
Reference in new issue
Block a user