fix: T4 browser-check findings, interval on season state, T6 plan refresh
- Honey batch create omits an empty batch number (was 400); product form
labels the legacy total weight, detail shows jar weight and shelf life;
bottling hints next to their fields, values cleared on product change,
post-save water-content wording; withdraw reason placeholder not
selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
nicht."); backup import gives its disabled reason; missing event labels
plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
without step-up).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
9760ce154f
commit
7a1480138d
63 files changed
+1076
-251
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
/**
|
||||
* "Weicht vom Artikel ab", right under the field it is about. Plain text the
|
||||
* field points at with aria-describedby - not a live region, the one live
|
||||
* region of the form stays at the top.
|
||||
*/
|
||||
export function DifferenceHint({ id, message }: { id: string; message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p id={id} className="flex items-start gap-2 text-sm">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span className="font-medium">Hinweis</span>
|
||||
<span>{message}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -276,6 +276,53 @@ describe("NewBottlingBatchPage", () => {
|
||||
expect(screen.getByText("Hinweis")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the differs hint under its own field, outside every live region", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
const jarSize = screen.getByLabelText(/Glasgröße/);
|
||||
await userEvent.clear(jarSize);
|
||||
await userEvent.type(jarSize, "500");
|
||||
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
|
||||
target: { value: "2029-01-01" },
|
||||
});
|
||||
|
||||
const jarHint = screen.getByText("Glasgröße weicht vom Artikel ab (Artikel: 250 g).");
|
||||
const bbdHint = screen.getByText("MHD weicht vom Artikel ab (Artikel: 14.10.2028).");
|
||||
expect(screen.getByLabelText(/Glasgröße/).getAttribute("aria-describedby")).toContain(
|
||||
jarHint.closest("p")!.id,
|
||||
);
|
||||
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/).getAttribute("aria-describedby")).toContain(
|
||||
bbdHint.closest("p")!.id,
|
||||
);
|
||||
expect(jarHint.closest("[aria-live]")).toBeNull();
|
||||
expect(bbdHint.closest("[aria-live]")).toBeNull();
|
||||
expect(screen.getAllByText(/weicht vom Artikel ab/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("clears jar size and best before typed for the previous product when the new one has no values", async () => {
|
||||
vi.mocked(fetchProducts).mockResolvedValue([
|
||||
{ id: "p-1", name: "Frühtracht 250 g", kind: "BOTTLED_HONEY" },
|
||||
{ id: "p-3", name: "Honig ohne Angaben", kind: "BOTTLED_HONEY" },
|
||||
] as never);
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
const jarSize = screen.getByLabelText(/Glasgröße/);
|
||||
await userEvent.clear(jarSize);
|
||||
await userEvent.type(jarSize, "300");
|
||||
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
|
||||
target: { value: "2027-01-31" },
|
||||
});
|
||||
|
||||
await chooseProduct("Honig ohne Angaben");
|
||||
|
||||
await screen.findByText(/Am Artikel ist keine Haltbarkeit hinterlegt/);
|
||||
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("");
|
||||
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
|
||||
expect(screen.queryByText(/weicht vom Artikel ab/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fills jar size and best before from the product and names the source", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
@@ -343,7 +390,7 @@ describe("NewBottlingBatchPage", () => {
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toHaveTextContent(
|
||||
"Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?",
|
||||
);
|
||||
@@ -364,15 +411,39 @@ describe("NewBottlingBatchPage", () => {
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1"));
|
||||
});
|
||||
|
||||
it("words the toast after the confirmed save as a fact, not as the pre-save text", async () => {
|
||||
vi.mocked(createBottlingBatch)
|
||||
.mockRejectedValueOnce(confirmationRequired(22))
|
||||
.mockResolvedValueOnce({
|
||||
id: "b-1",
|
||||
warnings: [
|
||||
{
|
||||
code: "WATER_CONTENT_ABOVE_LEGAL",
|
||||
severity: "BESTAETIGEN",
|
||||
message: "Der Wassergehalt (22 %) liegt über der gesetzlichen Grenze von 20 %. Abfüllen nur mit Bestätigung.",
|
||||
refs: [],
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
|
||||
const options = vi.mocked(toast.warning).mock.calls[0][1] as { description: string };
|
||||
expect(options.description).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
|
||||
expect(options.description).not.toMatch(/nur mit Bestätigung/);
|
||||
});
|
||||
|
||||
it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => {
|
||||
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
await screen.findByRole("dialog");
|
||||
await screen.findByRole("alertdialog");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
|
||||
expect(createBottlingBatch).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
@@ -388,11 +459,11 @@ describe("NewBottlingBatchPage", () => {
|
||||
.mockResolvedValueOnce({ id: "b-1" } as never);
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
await screen.findByRole("dialog");
|
||||
await screen.findByRole("alertdialog");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(
|
||||
expect(screen.getByRole("alertdialog")).toHaveTextContent(
|
||||
"Wassergehalt 21 % über der gesetzlichen Grenze.",
|
||||
),
|
||||
);
|
||||
@@ -524,11 +595,11 @@ describe("NewBottlingBatchPage", () => {
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch."));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a double click on Trotzdem abfüllen sends exactly one request", async () => {
|
||||
@@ -538,12 +609,12 @@ describe("NewBottlingBatchPage", () => {
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await screen.findByRole("dialog");
|
||||
await screen.findByRole("alertdialog");
|
||||
|
||||
await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
expect(createBottlingBatch).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Tab stays inside the dialog", async () => {
|
||||
@@ -552,7 +623,7 @@ describe("NewBottlingBatchPage", () => {
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
|
||||
const outside = [
|
||||
screen.getByLabelText(/Anzahl Gläser/),
|
||||
@@ -578,7 +649,7 @@ describe("NewBottlingBatchPage", () => {
|
||||
onCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %");
|
||||
expect(screen.getByRole("alertdialog")).toHaveTextContent("Wassergehalt 20,5 %");
|
||||
|
||||
rerender(
|
||||
<WaterContentConfirmDialog
|
||||
@@ -590,7 +661,7 @@ describe("NewBottlingBatchPage", () => {
|
||||
onCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const closing = screen.queryByRole("dialog");
|
||||
const closing = screen.queryByRole("alertdialog");
|
||||
if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %");
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
|
||||
import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types";
|
||||
import { postSaveWarnings } from "@/lib/bottling/post-save-warnings";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import type { PlausibilityWarning } from "@/lib/warnings/types";
|
||||
import { PrefillStatus } from "@/components/warnings/prefill-status";
|
||||
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
@@ -31,6 +31,7 @@ import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
|
||||
import { WaterContentNotes } from "./water-content-notes";
|
||||
import { DifferenceHint } from "./difference-hint";
|
||||
import {
|
||||
BottlingFormValues,
|
||||
initialBottlingValues,
|
||||
@@ -89,6 +90,10 @@ type BottlingDraft = BottlingFormValues & { manual?: ManualEdits };
|
||||
/** The prefill with the batch and product it was asked for. */
|
||||
type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string };
|
||||
|
||||
function joinIds(...ids: (string | undefined)[]): string | undefined {
|
||||
return ids.filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
|
||||
function germanDay(day: string): string {
|
||||
return day.split("-").reverse().join(".");
|
||||
}
|
||||
@@ -178,14 +183,25 @@ export default function NewBottlingBatchPage() {
|
||||
key: K,
|
||||
value: BottlingFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
if (key === "productId") {
|
||||
// A new product brings new master data; earlier typing no longer applies.
|
||||
// A new product brings new master data; jar size and best before typed
|
||||
// for the previous product no longer apply and must not stay next to
|
||||
// the new product's text (e.g. "Am Artikel ist keine Haltbarkeit ...").
|
||||
const fresh = initialBottlingValues("");
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
productId: value as string,
|
||||
jarSize: fresh.jarSize,
|
||||
bestBeforeDate: fresh.bestBeforeDate,
|
||||
}));
|
||||
setManual(NO_MANUAL_EDITS);
|
||||
setFromDraft(NO_MANUAL_EDITS);
|
||||
} else if (key === "jarSize" || key === "bestBeforeDate") {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setManual((current) => ({ ...current, [key]: true }));
|
||||
setFromDraft((current) => ({ ...current, [key]: false }));
|
||||
} else {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
setDirty(true);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
@@ -207,14 +223,17 @@ export default function NewBottlingBatchPage() {
|
||||
},
|
||||
{ idempotencyKey: idempotencyKey.current },
|
||||
),
|
||||
onSuccess: (bottlingBatch) => {
|
||||
onSuccess: (bottlingBatch, acknowledged) => {
|
||||
draft.clear();
|
||||
idempotencyKey.current = newIdempotencyKey();
|
||||
lastBody.current = null;
|
||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
if (bottlingBatch.warnings?.length) {
|
||||
toastWarnings(bottlingBatch.warnings, "Abfüllung angelegt");
|
||||
toastWarnings(
|
||||
postSaveWarnings(bottlingBatch.warnings, acknowledged ? confirmation : null),
|
||||
"Abfüllung angelegt",
|
||||
);
|
||||
} else {
|
||||
toast.success("Abfüllung angelegt");
|
||||
}
|
||||
@@ -326,25 +345,27 @@ export default function NewBottlingBatchPage() {
|
||||
: bestBeforeBase;
|
||||
|
||||
// The entered values against the product, before saving (hints, never a block).
|
||||
const localNotes: PlausibilityWarning[] = [];
|
||||
if (prefill && formValues.productId) {
|
||||
if (prefill.jarSize != null && values.jarSize.trim() !== "" && Number(values.jarSize) !== prefill.jarSize) {
|
||||
localNotes.push({
|
||||
code: "JAR_SIZE_DIFFERS",
|
||||
severity: "HINWEIS",
|
||||
message: `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`,
|
||||
refs: [],
|
||||
});
|
||||
}
|
||||
if (prefill.bestBeforeDate && values.bestBeforeDate && values.bestBeforeDate !== prefill.bestBeforeDate) {
|
||||
localNotes.push({
|
||||
code: "BEST_BEFORE_DIFFERS",
|
||||
severity: "HINWEIS",
|
||||
message: `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`,
|
||||
refs: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Shown right under the field they are about, not in the live region.
|
||||
const jarSizeDiffers =
|
||||
prefill &&
|
||||
formValues.productId &&
|
||||
prefill.jarSize != null &&
|
||||
values.jarSize.trim() !== "" &&
|
||||
Number(values.jarSize) !== prefill.jarSize
|
||||
? `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`
|
||||
: null;
|
||||
const bestBeforeDiffers =
|
||||
prefill &&
|
||||
formValues.productId &&
|
||||
prefill.bestBeforeDate &&
|
||||
values.bestBeforeDate &&
|
||||
values.bestBeforeDate !== prefill.bestBeforeDate
|
||||
? `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`
|
||||
: null;
|
||||
const shownAtField = [
|
||||
jarSizeDiffers ? "JAR_SIZE_DIFFERS" : null,
|
||||
bestBeforeDiffers ? "BEST_BEFORE_DIFFERS" : null,
|
||||
].filter((code): code is string => code !== null);
|
||||
|
||||
const prefillLoading = prefillQuery.isFetching;
|
||||
const submitDescribedBy =
|
||||
@@ -420,7 +441,7 @@ export default function NewBottlingBatchPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<WaterContentNotes prefill={prefill} localNotes={localNotes} />
|
||||
<WaterContentNotes prefill={prefill} shownAtFieldCodes={shownAtField} />
|
||||
<PrefillStatus
|
||||
enabled={prefillEnabled}
|
||||
isLoading={prefillQuery.isLoading}
|
||||
@@ -487,19 +508,22 @@ export default function NewBottlingBatchPage() {
|
||||
hint={jarSizeHint}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="jarSize"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.jarSize}
|
||||
onChange={(event) => set("jarSize", event.target.value)}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={id}
|
||||
data-field="jarSize"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={joinIds(describedBy, jarSizeDiffers ? `${id}-differs` : undefined)}
|
||||
aria-invalid={invalid}
|
||||
value={values.jarSize}
|
||||
onChange={(event) => set("jarSize", event.target.value)}
|
||||
/>
|
||||
<DifferenceHint id={`${id}-differs`} message={jarSizeDiffers} />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
@@ -531,19 +555,22 @@ export default function NewBottlingBatchPage() {
|
||||
hint={bestBeforeHint}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="bestBeforeDate"
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={values.bestBeforeDate}
|
||||
onChange={(event) =>
|
||||
set("bestBeforeDate", event.target.value)
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={id}
|
||||
data-field="bestBeforeDate"
|
||||
type="date"
|
||||
required
|
||||
className="h-11"
|
||||
aria-describedby={joinIds(describedBy, bestBeforeDiffers ? `${id}-differs` : undefined)}
|
||||
aria-invalid={invalid}
|
||||
value={values.bestBeforeDate}
|
||||
onChange={(event) =>
|
||||
set("bestBeforeDate", event.target.value)
|
||||
}
|
||||
/>
|
||||
<DifferenceHint id={`${id}-differs`} message={bestBeforeDiffers} />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function WaterContentConfirmDialog({
|
||||
if (!open && !isPending) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent finalFocus={returnFocusTo}>
|
||||
<DialogContent role="alertdialog" finalFocus={returnFocusTo}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wassergehalt zu hoch</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertTriangle, Info, ShieldAlert } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { formatWaterContent } from "@/lib/bottling/format";
|
||||
import type { BottlingPrefill } from "@/lib/bottling/types";
|
||||
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
|
||||
import type { WarningSeverity } from "@/lib/warnings/types";
|
||||
|
||||
const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = {
|
||||
HINWEIS: { label: "Hinweis", Icon: Info },
|
||||
@@ -17,17 +17,18 @@ const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentTyp
|
||||
*/
|
||||
export function WaterContentNotes({
|
||||
prefill,
|
||||
localNotes = [],
|
||||
shownAtFieldCodes = [],
|
||||
}: {
|
||||
prefill: BottlingPrefill | undefined;
|
||||
/** Findings the form works out itself (entered value vs product), shown as hints. */
|
||||
localNotes?: PlausibilityWarning[];
|
||||
/**
|
||||
* Codes the form shows next to their field (entered value vs product). They
|
||||
* are left out here: one live region, no second announcement.
|
||||
*/
|
||||
shownAtFieldCodes?: string[];
|
||||
}) {
|
||||
const localCodes = new Set(localNotes.map((note) => note.code));
|
||||
const warnings = [
|
||||
...(prefill?.warnings ?? []).filter((warning) => !localCodes.has(warning.code)),
|
||||
...localNotes,
|
||||
];
|
||||
const warnings = (prefill?.warnings ?? []).filter(
|
||||
(warning) => !shownAtFieldCodes.includes(warning.code),
|
||||
);
|
||||
const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0;
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { addHoneyMeasurement } from "@/lib/honey-batches/api";
|
||||
import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -20,6 +21,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [waterContent, setWaterContent] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate());
|
||||
@@ -56,8 +58,15 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm">Neue Messung</Button>} />
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
|
||||
Neue Messung
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Honigmessung</DialogTitle>
|
||||
@@ -107,5 +116,7 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-1
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/honey-batches/api";
|
||||
import { HoneyBatch } from "@/lib/honey-batches/types";
|
||||
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -26,6 +27,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
// represent.
|
||||
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const [storageContainerId, setStorageContainerId] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -167,9 +169,16 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" variant="outline" disabled={assignMutation.isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={assignMutation.isPending || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
|
||||
</Button>
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
|
||||
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
|
||||
import { fetchExtractions } from "@/lib/extractions/api";
|
||||
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -38,6 +39,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
|
||||
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
|
||||
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
|
||||
const { data: extractions } = useQuery({
|
||||
queryKey: ["extractions"],
|
||||
queryFn: () => fetchExtractions(),
|
||||
@@ -72,10 +75,11 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button variant="ghost" size="touch" disabled={isOffline} aria-describedby={describedBy}>
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
@@ -174,5 +178,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api";
|
||||
import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types";
|
||||
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -25,6 +26,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [storageContainerId, setStorageContainerId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
@@ -72,8 +74,15 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Lagerbehälter</CardTitle>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm">Umlagern</Button>} />
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
|
||||
Umlagern
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Honigcharge umlagern</DialogTitle>
|
||||
@@ -111,6 +120,8 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>
|
||||
|
||||
+10
@@ -37,6 +37,16 @@ describe("HoneyBatchWithdrawDialog", () => {
|
||||
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("lists the reasons but not the placeholder „Grund wählen“ as a choice", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
|
||||
const placeholder = await screen.findByRole("option", { name: "Grund wählen" });
|
||||
expect(placeholder).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("option", { name: "Hofladen" })).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("refuses more than the rest and less than a gram in words", async () => {
|
||||
renderDialog();
|
||||
const quantity = await open();
|
||||
|
||||
@@ -147,6 +147,7 @@ export function HoneyBatchWithdrawDialog({
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
noneLabel="Grund wählen"
|
||||
noneIsPlaceholder
|
||||
labels={WITHDRAWAL_REASON_LABELS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import type { HoneyBatch } from "@/lib/honey-batches/types";
|
||||
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
|
||||
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
|
||||
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
|
||||
import { AddMeasurementDialog } from "./add-measurement-dialog";
|
||||
|
||||
vi.mock("@/lib/honey-batches/api", () => ({
|
||||
updateHoneyBatch: vi.fn(),
|
||||
fetchHoneyBatchRelocations: vi.fn(async () => []),
|
||||
relocateHoneyBatch: vi.fn(),
|
||||
fetchHoneyBatchContainerAllocations: vi.fn(async () => []),
|
||||
setHoneyBatchContainerAllocation: vi.fn(),
|
||||
removeHoneyBatchContainerAllocation: vi.fn(),
|
||||
addHoneyMeasurement: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/extractions/api", () => ({ fetchExtractions: vi.fn(async () => []) }));
|
||||
vi.mock("@/lib/storage/api", () => ({ fetchStorageContainers: vi.fn(async () => []) }));
|
||||
|
||||
const HONEY_BATCH = {
|
||||
id: "h1",
|
||||
batchNumber: "H-1",
|
||||
extractionId: "x1",
|
||||
honeyType: null,
|
||||
amount: 20,
|
||||
waterContent: null,
|
||||
storageContainerId: null,
|
||||
storageContainer: null,
|
||||
status: "STORED",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
measurements: [],
|
||||
} as HoneyBatch;
|
||||
|
||||
const ACTIONS: { name: string; button: RegExp; ui: () => React.ReactElement }[] = [
|
||||
{ name: "Bearbeiten", button: /Bearbeiten/, ui: () => <HoneyBatchEditDialog honeyBatch={HONEY_BATCH} /> },
|
||||
{ name: "Umlagern", button: /^Umlagern$/, ui: () => <HoneyBatchStorageCard honeyBatch={HONEY_BATCH} /> },
|
||||
{
|
||||
name: "Behälter zuweisen",
|
||||
button: /Behälter zuweisen/,
|
||||
ui: () => <HoneyBatchContainerAllocationsCard honeyBatch={HONEY_BATCH} />,
|
||||
},
|
||||
{ name: "Neue Messung", button: /Neue Messung/, ui: () => <AddMeasurementDialog honeyBatchId="h1" /> },
|
||||
];
|
||||
|
||||
function renderUi(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("online-only actions of the honey batch page", () => {
|
||||
beforeEach(() => act(() => reportRequestOutcome("reached-server")));
|
||||
afterEach(() => act(() => reportRequestOutcome("reached-server")));
|
||||
|
||||
for (const action of ACTIONS) {
|
||||
it(`${action.name}: enabled and 44 px high online, no hint`, () => {
|
||||
renderUi(action.ui());
|
||||
const button = screen.getByRole("button", { name: action.button });
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).toHaveClass("h-11");
|
||||
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it(`${action.name}: disabled offline with the visible reason „Nur mit Verbindung“`, () => {
|
||||
renderUi(action.ui());
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
const button = screen.getByRole("button", { name: action.button });
|
||||
const reason = screen.getByText("Nur mit Verbindung");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("aria-describedby")).toBe(reason.id);
|
||||
expect(button).toHaveClass("h-11");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -134,4 +134,17 @@ describe("CreateHoneyBatchDialog", () => {
|
||||
expect(createHoneyBatch).toHaveBeenCalled();
|
||||
expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt");
|
||||
});
|
||||
|
||||
it("omits an empty batch number instead of sending null (the DTO rejects null)", async () => {
|
||||
vi.mocked(createHoneyBatch).mockResolvedValue({ id: "b1" } as never);
|
||||
renderDialog();
|
||||
await openAndChooseExtraction();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
|
||||
|
||||
const payload = vi.mocked(createHoneyBatch).mock.calls[0][0];
|
||||
expect(payload.extractionId).toBe("x1");
|
||||
expect(payload.batchNumber).toBeUndefined();
|
||||
expect(JSON.parse(JSON.stringify(payload))).not.toHaveProperty("batchNumber");
|
||||
});
|
||||
});
|
||||
@@ -86,8 +86,9 @@ export function CreateHoneyBatchDialog() {
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
// Empty: the server generates the number (automation format).
|
||||
batchNumber: emptyToNull(batchNumber),
|
||||
// Empty: omitted, the server generates the number (automation format).
|
||||
// The create DTO is IsOptionalNotNull, so null would be a 400.
|
||||
batchNumber: emptyToNull(batchNumber) ?? undefined,
|
||||
extractionId,
|
||||
honeyType: emptyToNull(honeyType),
|
||||
amount: emptyToNull(amount, Number),
|
||||
|
||||
@@ -93,4 +93,32 @@ describe("ProductDetailPage", () => {
|
||||
const link = (await screen.findByText("K-1")).closest("a");
|
||||
expect(link).toHaveAttribute("href", "/production-batches/b1");
|
||||
});
|
||||
|
||||
it("shows the jar weight and the shelf life in the details when they are set", async () => {
|
||||
vi.mocked(fetchProduct).mockResolvedValue({
|
||||
id: "candle",
|
||||
name: "Honig 250",
|
||||
description: null,
|
||||
sku: null,
|
||||
packagingSize: 250,
|
||||
shelfLifeMonths: 18,
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
status: "ACTIVE",
|
||||
kind: "BOTTLED_HONEY",
|
||||
shopCategory: "HONIG",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
renderPage();
|
||||
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
|
||||
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides jar weight and shelf life when they are not set", async () => {
|
||||
renderPage();
|
||||
await screen.findByTestId("total-remaining");
|
||||
expect(screen.queryByText(/Gewicht pro Glas/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Haltbarkeit/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,12 @@ export default function ProductDetailPage() {
|
||||
<p>
|
||||
Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]}
|
||||
</p>
|
||||
{product.packagingSize != null ? (
|
||||
<p>Gewicht pro Glas: {product.packagingSize.toLocaleString("de-DE")} g</p>
|
||||
) : null}
|
||||
{product.shelfLifeMonths != null ? (
|
||||
<p>Haltbarkeit: {product.shelfLifeMonths} Monate</p>
|
||||
) : null}
|
||||
{product.sku ? <p>SKU: {product.sku}</p> : null}
|
||||
{product.salesUnit ? (
|
||||
<p>Verkaufseinheit: {product.salesUnit}</p>
|
||||
|
||||
@@ -36,6 +36,13 @@ async function openDialog(product: Product = PRODUCT) {
|
||||
}
|
||||
|
||||
describe("EditProductDialog", () => {
|
||||
it("labels the second weight field so it cannot be mistaken for the jar weight", async () => {
|
||||
await openDialog();
|
||||
expect(screen.getByLabelText(/Gewicht pro Glas \(g\)/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Artikelgewicht gesamt \(g\)/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^Gewicht$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(updateProduct).mockReset();
|
||||
vi.mocked(updateProduct).mockResolvedValue(PRODUCT);
|
||||
|
||||
@@ -205,15 +205,20 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-weight">Gewicht</Label>
|
||||
<Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
|
||||
<Input
|
||||
id="product-edit-weight"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={0}
|
||||
aria-describedby="product-edit-weight-hint"
|
||||
value={weight}
|
||||
onChange={(event) => setWeight(event.target.value)}
|
||||
/>
|
||||
<p id="product-edit-weight-hint" className="text-xs text-muted-foreground">
|
||||
Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-status">Status</Label>
|
||||
|
||||
@@ -221,8 +221,8 @@ export default function NewProductPage() {
|
||||
fieldCount={3}
|
||||
>
|
||||
<Field
|
||||
label="Gewicht (g)"
|
||||
hint="in Gramm"
|
||||
label="Artikelgewicht gesamt (g)"
|
||||
hint="Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas"
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
|
||||
@@ -35,6 +35,12 @@ describe("DangerZoneSection", () => {
|
||||
logoutMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("the delete button is 44 px high", () => {
|
||||
renderZone();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Konto löschen/ })).toHaveClass("h-11");
|
||||
});
|
||||
|
||||
it("signs out and deletes the unsent entries of a deactivated account", async () => {
|
||||
vi.mocked(deactivateAccount).mockResolvedValue(undefined);
|
||||
renderZone();
|
||||
|
||||
@@ -58,7 +58,7 @@ function DeactivateAccountDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||
<Button type="button" variant="destructive" onClick={() => setOpen(true)}>
|
||||
<Button type="button" size="touch" variant="destructive" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
Konto löschen
|
||||
</Button>
|
||||
@@ -88,10 +88,10 @@ function DeactivateAccountDialog() {
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={mutation.isPending}>
|
||||
<Button type="submit" size="touch" variant="destructive" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -85,7 +85,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||
<Button type="button" onClick={handleTrigger}>
|
||||
<Button type="button" size="touch" onClick={handleTrigger}>
|
||||
Einrichten
|
||||
</Button>
|
||||
<DialogContent>
|
||||
@@ -134,10 +134,10 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={confirmMutation.isPending}>
|
||||
<Button type="submit" size="touch" disabled={confirmMutation.isPending}>
|
||||
{confirmMutation.isPending ? "Prüfe…" : "Bestätigen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -183,7 +183,7 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Button type="button" size="touch" variant="outline" onClick={() => setOpen(true)}>
|
||||
Deaktivieren
|
||||
</Button>
|
||||
<DialogContent>
|
||||
@@ -224,10 +224,10 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={disableMutation.isPending}>
|
||||
<Button type="submit" size="touch" variant="destructive" disabled={disableMutation.isPending}>
|
||||
{disableMutation.isPending ? "Wird deaktiviert…" : "Deaktivieren"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -92,4 +92,12 @@ describe("ProfilePage", () => {
|
||||
expect(vi.mocked(uploadAvatar).mock.calls[0]?.[0]).toBe(file);
|
||||
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps the profile buttons at 44 px (touch size)", async () => {
|
||||
renderWithClient(<ProfilePage />);
|
||||
|
||||
for (const name of ["Bild wählen", "Speichern", "Passwort ändern"]) {
|
||||
expect(await screen.findByRole("button", { name })).toHaveClass("h-11");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,7 @@ function AvatarSection() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -96,6 +97,7 @@ function AvatarSection() {
|
||||
{user.hasAvatar ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
@@ -205,7 +207,7 @@ function ProfileInfoSection() {
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="self-start" disabled={mutation.isPending}>
|
||||
<Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -276,7 +278,7 @@ function PasswordSection() {
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="self-start" disabled={mutation.isPending}>
|
||||
<Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Ändert…" : "Passwort ändern"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -72,7 +72,7 @@ function renderCard() {
|
||||
async function connect(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("TelegramLinkCard", () => {
|
||||
renderCard();
|
||||
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
|
||||
await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("TelegramLinkCard", () => {
|
||||
renderCard();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Trennen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(unlinkTelegram).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("TelegramLinkCard", () => {
|
||||
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked);
|
||||
await user.click(await screen.findByRole("button", { name: "Trennen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Telegram verbinden" });
|
||||
@@ -235,7 +235,7 @@ describe("TelegramLinkCard", () => {
|
||||
renderCard();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Trennen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
|
||||
|
||||
const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" });
|
||||
|
||||
@@ -281,7 +281,7 @@ export function TelegramLinkCard() {
|
||||
open={isStepUpOpen}
|
||||
onOpenChange={setIsStepUpOpen}
|
||||
title="Telegram verbinden?"
|
||||
description="Zur Sicherheit fragen wir noch einmal nach Ihrem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
|
||||
description="Zur Sicherheit fragen wir noch einmal nach deinem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
|
||||
confirmLabel="Link erzeugen"
|
||||
needsMfa={Boolean(user?.mfaEnabled)}
|
||||
onConfirm={createLink}
|
||||
|
||||
@@ -178,17 +178,32 @@ describe("BackupPage", () => {
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
|
||||
}
|
||||
|
||||
it("explains why „Backup importieren“ is off until a file is chosen", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
renderWithClient(<BackupPage />);
|
||||
await screen.findByText("Aktiv");
|
||||
|
||||
const button = screen.getByRole("button", { name: "Backup importieren" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAccessibleDescription("Bitte zuerst eine Backup-Datei wählen.");
|
||||
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
|
||||
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
|
||||
});
|
||||
expect(button).toBeEnabled();
|
||||
expect(screen.queryByText("Bitte zuerst eine Backup-Datei wählen.")).toBeNull();
|
||||
});
|
||||
|
||||
it("the backup import still asks for the password through StepUpDialog", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
|
||||
renderWithClient(<BackupPage />);
|
||||
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
|
||||
// No loose password field on the page any more.
|
||||
expect(screen.queryByLabelText("Ihr Passwort")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Dein Passwort")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
|
||||
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
|
||||
});
|
||||
@@ -196,7 +211,7 @@ describe("BackupPage", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled();
|
||||
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
@@ -222,7 +237,7 @@ describe("BackupPage", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
|
||||
// Still open, file still chosen: a second try needs only the password.
|
||||
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "richtig");
|
||||
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "richtig");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
|
||||
await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File);
|
||||
|
||||
@@ -165,17 +165,23 @@ function BackupSection() {
|
||||
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
|
||||
/>
|
||||
<p className="text-muted-foreground">
|
||||
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort
|
||||
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir dein Passwort
|
||||
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!file || importMutation.isPending}
|
||||
aria-describedby={file ? undefined : "backup-import-hint"}
|
||||
onClick={() => setIsImportDialogOpen(true)}
|
||||
>
|
||||
Backup importieren
|
||||
</Button>
|
||||
{file ? null : (
|
||||
<p id="backup-import-hint" className="text-xs text-muted-foreground">
|
||||
Bitte zuerst eine Backup-Datei wählen.
|
||||
</p>
|
||||
)}
|
||||
<StepUpDialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("TelegramCard", () => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
|
||||
expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen.");
|
||||
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
|
||||
@@ -168,7 +168,7 @@ describe("TelegramCard", () => {
|
||||
await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
|
||||
expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen.");
|
||||
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("StepUpDialog", () => {
|
||||
const confirm = screen.getByRole("button", { name: "Verbinden" });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
|
||||
await userEvent.click(confirm);
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ password: "geheim" }));
|
||||
@@ -39,7 +39,7 @@ describe("StepUpDialog", () => {
|
||||
it("asks for the six-digit code with MFA and sends both", async () => {
|
||||
const { onConfirm } = renderDialog({ needsMfa: true });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
|
||||
const confirm = screen.getByRole("button", { name: "Verbinden" });
|
||||
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "12ab34");
|
||||
expect(confirm).toBeDisabled();
|
||||
@@ -56,21 +56,21 @@ describe("StepUpDialog", () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new ApiError(401, "Das Passwort ist falsch."));
|
||||
const { onOpenChange } = renderDialog({ onConfirm });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "falsch");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "falsch");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
|
||||
);
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
expect(screen.getByLabelText("Ihr Passwort")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Ihr Passwort")).toHaveFocus();
|
||||
expect(screen.getByLabelText("Dein Passwort")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Dein Passwort")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("shows the German too-many-attempts text on 429", async () => {
|
||||
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(429, "ThrottlerException")) });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "x");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -83,7 +83,7 @@ describe("StepUpDialog", () => {
|
||||
it("says the session expired on a bare 401", async () => {
|
||||
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(401, "Unauthorized")) });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "x");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -96,17 +96,17 @@ describe("StepUpDialog", () => {
|
||||
it("makes the inputs read-only while the action runs", async () => {
|
||||
renderDialog({ onConfirm: vi.fn(() => new Promise<void>(() => {})) });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Ihr Passwort")).toHaveAttribute("readonly"));
|
||||
await waitFor(() => expect(screen.getByLabelText("Dein Passwort")).toHaveAttribute("readonly"));
|
||||
});
|
||||
|
||||
it("cannot be closed with Escape while the action runs, and shows the pending label", async () => {
|
||||
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
|
||||
const { onOpenChange } = renderDialog({ onConfirm, pendingLabel: "Wird eingespielt…" });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
expect(await screen.findByRole("button", { name: "Wird eingespielt…" })).toBeDisabled();
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function StepUpForm({
|
||||
return (
|
||||
<form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={passwordId}>Ihr Passwort</Label>
|
||||
<Label htmlFor={passwordId}>Dein Passwort</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
ref={passwordRef}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConfirmDialog } from "./confirm-dialog";
|
||||
|
||||
function renderDialog(onConfirm = vi.fn()) {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
title="Wirklich löschen?"
|
||||
description="Das lässt sich nicht rückgängig machen."
|
||||
confirmLabel="Ja, löschen"
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
return onConfirm;
|
||||
}
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("is an alertdialog named by its title and described by its text", async () => {
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Wirklich löschen?" });
|
||||
expect(dialog).toHaveAccessibleDescription("Das lässt sich nicht rückgängig machen.");
|
||||
});
|
||||
|
||||
it("confirms, cancels and closes with Escape as before", async () => {
|
||||
const onConfirm = renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
|
||||
await screen.findByRole("alertdialog");
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ja, löschen" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export function ConfirmDialog({
|
||||
>
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogContent role="alertdialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
|
||||
@@ -45,6 +45,11 @@ type EnumSelectProps<T extends string> = EnumSelectBaseProps<T> &
|
||||
* Status"). It stands for the empty value "".
|
||||
*/
|
||||
noneLabel: string;
|
||||
/**
|
||||
* The "none" row is only a placeholder ("Grund wählen"): it shows in
|
||||
* the trigger while nothing is chosen but cannot be picked.
|
||||
*/
|
||||
noneIsPlaceholder?: boolean;
|
||||
value: NoInfer<T> | "";
|
||||
onChange: (value: NoInfer<T> | "") => void;
|
||||
}
|
||||
@@ -103,7 +108,9 @@ export function EnumSelect<T extends string>(props: EnumSelectProps<T>) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.noneLabel !== undefined ? (
|
||||
<SelectItem value={NONE_VALUE}>{props.noneLabel}</SelectItem>
|
||||
<SelectItem value={NONE_VALUE} disabled={props.noneIsPlaceholder}>
|
||||
{props.noneLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{values.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
|
||||
@@ -37,4 +37,14 @@ describe("PreviousValueHint", () => {
|
||||
expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the separator and „übernehmen“ in one non-wrapping unit", () => {
|
||||
render(<PreviousValueHint fieldLabel="Menge" valueLabel="20 kg" onTakeOver={() => undefined} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /übernehmen/ });
|
||||
const unit = button.parentElement as HTMLElement;
|
||||
expect(unit).toHaveClass("whitespace-nowrap");
|
||||
expect(unit).toHaveTextContent(/^·\s*übernehmen/);
|
||||
expect(screen.getByText(/Letztes Mal: 20 kg/)).not.toHaveTextContent("·");
|
||||
});
|
||||
});
|
||||
@@ -28,16 +28,20 @@ export function PreviousValueHint({
|
||||
return (
|
||||
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
|
||||
<span>
|
||||
{prefix}: {valueLabel} <span aria-hidden="true">·</span>
|
||||
{prefix}: {valueLabel}
|
||||
</span>
|
||||
{/* Separator and button wrap as one unit: the dot is never left alone at a line end. */}
|
||||
<span className="inline-flex items-center whitespace-nowrap">
|
||||
<span aria-hidden="true">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTakeOver}
|
||||
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
übernehmen{" "}
|
||||
<span className="sr-only">– {fieldLabel}</span>
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTakeOver}
|
||||
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
übernehmen{" "}
|
||||
<span className="sr-only">– {fieldLabel}</span>
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async function discardRejectedEntry(nextSummary: { own: number; failed: number;
|
||||
await userEvent.click(
|
||||
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
|
||||
);
|
||||
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
|
||||
const confirm = await screen.findByRole("alertdialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
|
||||
return sheet;
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("PendingQueueSheet", () => {
|
||||
await userEvent.click(
|
||||
within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }),
|
||||
);
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
const confirm = await screen.findByRole("alertdialog", {
|
||||
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
|
||||
});
|
||||
expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht");
|
||||
@@ -204,7 +204,7 @@ describe("PendingQueueSheet", () => {
|
||||
const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`;
|
||||
|
||||
await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` }));
|
||||
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
|
||||
const confirm = await screen.findByRole("alertdialog", { name: `${name} verwerfen?` });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1"));
|
||||
@@ -226,7 +226,7 @@ describe("PendingQueueSheet", () => {
|
||||
await userEvent.click(discardRejected);
|
||||
expect(discardQueueItem).not.toHaveBeenCalled();
|
||||
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
const confirm = await screen.findByRole("alertdialog", {
|
||||
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
|
||||
});
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
@@ -270,7 +270,7 @@ describe("PendingQueueSheet", () => {
|
||||
expect(names[2]).toMatch(/\(2\)$/);
|
||||
await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]);
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
|
||||
await screen.findByRole("alertdialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||
import { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api";
|
||||
import type { PhenologyState } from "@/lib/phenology/types";
|
||||
import { ApiarySeason } from "./apiary-season";
|
||||
@@ -14,10 +13,6 @@ vi.mock("@/lib/phenology/api", async (importOriginal) => ({
|
||||
fetchPhenologyReports: vi.fn().mockResolvedValue([]),
|
||||
createPhenologyReport: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
|
||||
fetchAutopilotToday: vi.fn(),
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useCurrentUserId: () => "user-1",
|
||||
@@ -39,6 +34,7 @@ function state(overrides: Partial<PhenologyState>): PhenologyState {
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: null,
|
||||
intervalDays: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -62,23 +58,6 @@ beforeEach(() => {
|
||||
state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }),
|
||||
state({}),
|
||||
]);
|
||||
vi.mocked(fetchAutopilotToday)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
nextStep: null,
|
||||
inspectionDue: [
|
||||
{
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
segment: "SCHWARMZEIT",
|
||||
intervalDays: 7,
|
||||
colonies: [],
|
||||
},
|
||||
],
|
||||
suggestionsOpen: 0,
|
||||
seasons: [],
|
||||
tasksVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiarySeason", () => {
|
||||
@@ -105,8 +84,8 @@ describe("ApiarySeason", () => {
|
||||
expect(await screen.findByText("Kalender")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the season without an interval when the interval is unknown", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockRejectedValue(new Error("403"));
|
||||
it("shows just the season when no interval applies", async () => {
|
||||
vi.mocked(fetchPhenologyState).mockResolvedValue([state({ intervalDays: null })]);
|
||||
renderSeason();
|
||||
|
||||
expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument();
|
||||
@@ -121,6 +100,15 @@ describe("ApiarySeason", () => {
|
||||
expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("takes the interval from the state, without asking the autopilot", async () => {
|
||||
vi.mocked(fetchPhenologyState).mockResolvedValue([
|
||||
state({ segment: "SOMMER", intervalDays: 14 }),
|
||||
]);
|
||||
renderSeason();
|
||||
|
||||
expect(await screen.findByText("Sommer · alle 14 Tage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the button without phenology:create", async () => {
|
||||
auth.permissions = new Set();
|
||||
renderSeason();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Flower2 } from "lucide-react";
|
||||
import { SeasonChip } from "@/components/autopilot/season-chip";
|
||||
import { PhenologyReportSheet } from "@/components/phenology/phenology-report-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchPhenologyState, phenologyStateKey } from "@/lib/phenology/api";
|
||||
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, phaseSourceText } from "@/lib/phenology/labels";
|
||||
@@ -22,21 +21,13 @@ interface ApiarySeasonProps {
|
||||
export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const [isSheetOpen, setSheetOpen] = useState(false);
|
||||
const reportButton = useRef<HTMLButtonElement>(null);
|
||||
const stateQuery = useQuery({
|
||||
queryKey: phenologyStateKey,
|
||||
queryFn: fetchPhenologyState,
|
||||
});
|
||||
// The interval is only known for apiaries with colonies to inspect; without
|
||||
// it (or without the right to see it) the chip names just the season.
|
||||
const todayQuery = useQuery({
|
||||
queryKey: autopilotTodayKey,
|
||||
queryFn: fetchAutopilotToday,
|
||||
});
|
||||
|
||||
const state = stateQuery.data?.find((entry) => entry.apiaryId === apiary.id);
|
||||
const intervalDays =
|
||||
todayQuery.data?.inspectionDue.find((entry) => entry.apiaryId === apiary.id)?.intervalDays ??
|
||||
null;
|
||||
const source = state ? phaseSourceText(state.phaseSource, state.reportedOn) : null;
|
||||
const canReport = hasPermission("phenology:create");
|
||||
|
||||
@@ -46,7 +37,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
<section aria-label="Saison" className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{state ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<SeasonChip segment={state.segment} intervalDays={intervalDays} />
|
||||
<SeasonChip segment={state.segment} intervalDays={state.intervalDays} />
|
||||
{state.phase ? <span>{PHASE_REPORT_LABELS[state.phase]}</span> : null}
|
||||
{source ? (
|
||||
<>
|
||||
@@ -63,11 +54,22 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
) : null}
|
||||
{canReport ? (
|
||||
<>
|
||||
<Button type="button" size="touch" variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
<Button
|
||||
ref={reportButton}
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
>
|
||||
<Flower2 aria-hidden />
|
||||
Pflanze blüht
|
||||
</Button>
|
||||
<PhenologyReportSheet open={isSheetOpen} onOpenChange={setSheetOpen} apiary={apiary} />
|
||||
<PhenologyReportSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
apiary={apiary}
|
||||
returnFocusTo={reportButton}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("PhenologyReportSheet", () => {
|
||||
}),
|
||||
);
|
||||
expect(revokePhenologyReport).not.toHaveBeenCalled();
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
const dialog = await screen.findByRole("alertdialog", {
|
||||
name: "Für alle Stände zurückziehen?",
|
||||
});
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Zurückziehen" }));
|
||||
@@ -268,6 +268,21 @@ describe("PhenologyReportSheet", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Escape on the revoke question closes only that question, not the sheet", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
|
||||
const onOpenChange = renderSheet();
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: /Für alle Stände zurückziehen/ }),
|
||||
);
|
||||
await screen.findByRole("alertdialog", { name: "Für alle Stände zurückziehen?" });
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog", { name: "Entwicklung melden" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not withdraw when the question is cancelled", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
|
||||
renderSheet();
|
||||
@@ -277,7 +292,7 @@ describe("PhenologyReportSheet", () => {
|
||||
name: /Für alle Stände zurückziehen/,
|
||||
}),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
const dialog = await screen.findByRole("alertdialog", {
|
||||
name: "Für alle Stände zurückziehen?",
|
||||
});
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" }));
|
||||
@@ -298,6 +313,13 @@ describe("PhenologyReportSheet", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is a max-width card from md up, not a full-width bottom sheet", () => {
|
||||
renderSheet();
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Entwicklung melden" });
|
||||
expect(dialog).toHaveClass("md:w-[28rem]", "md:inset-x-auto", "md:right-4");
|
||||
});
|
||||
|
||||
it("has a neutral heading", () => {
|
||||
renderSheet();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Flower2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -41,6 +41,8 @@ interface PhenologyReportSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
apiary: { id: string; name: string };
|
||||
/** Where the focus goes when the sheet closes: the button that opened it. */
|
||||
returnFocusTo?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
type Scope = "all" | "apiary";
|
||||
@@ -48,7 +50,12 @@ type Scope = "all" | "apiary";
|
||||
const QUEUED_TEXT = "gespeichert – wird gesendet, sobald wieder Verbindung besteht";
|
||||
|
||||
/** Which plant is in bloom? One tap reports it for today. */
|
||||
export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyReportSheetProps) {
|
||||
export function PhenologyReportSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
apiary,
|
||||
returnFocusTo,
|
||||
}: PhenologyReportSheetProps) {
|
||||
// While a report or a withdrawal is on its way the sheet stays open, so the
|
||||
// answer (or the server's reason) is never lost behind a closed sheet.
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
@@ -61,7 +68,10 @@ export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyRe
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
|
||||
finalFocus={returnFocusTo}
|
||||
// Below md a full-width bottom sheet; from md a card at the bottom right,
|
||||
// so it does not cover the sidebar.
|
||||
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom) md:inset-x-auto md:right-4 md:bottom-4 md:w-[28rem] md:max-w-[calc(100vw-2rem)] md:rounded-2xl md:border"
|
||||
>
|
||||
{/* Remounted per opening: a fresh idempotency key and the preselected scope. */}
|
||||
{open ? (
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("PhotoCaptureField", () => {
|
||||
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" }));
|
||||
const confirm = await screen.findByRole("dialog", { name: "Foto 1 entfernen?" });
|
||||
const confirm = await screen.findByRole("alertdialog", { name: "Foto 1 entfernen?" });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" }));
|
||||
|
||||
await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1));
|
||||
|
||||
@@ -12,8 +12,8 @@ describe("stepUpErrorText", () => {
|
||||
});
|
||||
|
||||
it("passes a German reauth message through unchanged", () => {
|
||||
expect(stepUpErrorText(new ApiError(401, "Das Passwort ist falsch."), "x")).toBe(
|
||||
"Das Passwort ist falsch.",
|
||||
expect(stepUpErrorText(new ApiError(401, "Das Passwort stimmt nicht."), "x")).toBe(
|
||||
"Das Passwort stimmt nicht.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { postSaveWarnings } from "./post-save-warnings";
|
||||
import type { PlausibilityWarning } from "@/lib/warnings/types";
|
||||
|
||||
const WATER: PlausibilityWarning = {
|
||||
code: "WATER_CONTENT_ABOVE_LEGAL",
|
||||
severity: "BESTAETIGEN",
|
||||
message: "Der Wassergehalt (22 %) liegt über der Grenze. Abfüllen nur mit Bestätigung.",
|
||||
refs: [],
|
||||
};
|
||||
const OTHER: PlausibilityWarning = {
|
||||
code: "WITHDRAWAL_ACTIVE",
|
||||
severity: "WARNUNG",
|
||||
message: "Wartezeit läuft.",
|
||||
refs: [],
|
||||
};
|
||||
|
||||
describe("postSaveWarnings", () => {
|
||||
it("words the confirmed water content as a fact with the value", () => {
|
||||
const [warning] = postSaveWarnings([WATER], 22);
|
||||
expect(warning.message).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
|
||||
expect(warning.severity).toBe("BESTAETIGEN");
|
||||
});
|
||||
|
||||
it("falls back to a text without the value and leaves other warnings alone", () => {
|
||||
const result = postSaveWarnings([WATER, OTHER], null);
|
||||
expect(result[0].message).toBe("Wassergehalt über der Grenze – mit Bestätigung abgefüllt.");
|
||||
expect(result[1]).toBe(OTHER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PlausibilityWarning } from "@/lib/warnings/types";
|
||||
import { formatWaterContent } from "./format";
|
||||
import { WATER_CONTENT_ABOVE_LEGAL } from "./types";
|
||||
|
||||
/**
|
||||
* The server words its warnings for the time before saving ("... Abfüllen nur
|
||||
* mit Bestätigung."). After a confirmed save that text is out of date, so the
|
||||
* confirmed finding is worded as the fact it now is.
|
||||
*/
|
||||
export function postSaveWarnings(
|
||||
warnings: readonly PlausibilityWarning[],
|
||||
confirmedWaterContent: number | null,
|
||||
): PlausibilityWarning[] {
|
||||
return warnings.map((warning) =>
|
||||
warning.code === WATER_CONTENT_ABOVE_LEGAL
|
||||
? {
|
||||
...warning,
|
||||
message:
|
||||
confirmedWaterContent === null
|
||||
? "Wassergehalt über der Grenze – mit Bestätigung abgefüllt."
|
||||
: `Wassergehalt ${formatWaterContent(confirmedWaterContent)} über der Grenze – mit Bestätigung abgefüllt.`,
|
||||
}
|
||||
: warning,
|
||||
);
|
||||
}
|
||||
@@ -92,15 +92,75 @@ describe("fetch-with-timeout", () => {
|
||||
return new Response(new ReadableStream({ start() {} }), { status: 200 });
|
||||
}
|
||||
|
||||
it("a stalled body ends in TimeoutError and reports network-error", async () => {
|
||||
async function timeOut(): Promise<void> {
|
||||
vi.stubGlobal("fetch", vi.fn(hangingFetch));
|
||||
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
|
||||
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
await assertion;
|
||||
}
|
||||
async function answer(): Promise<void> {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
|
||||
await fetchReportingOutcome("/x", { timeoutMs: 4_000 });
|
||||
}
|
||||
|
||||
it("one timeout leaves the app online, two in a row set offline", async () => {
|
||||
reportRequestOutcome("reached-server");
|
||||
await timeOut();
|
||||
expect(getConnectivity()).toBe("online");
|
||||
await timeOut();
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
await answer();
|
||||
expect(getConnectivity()).toBe("online");
|
||||
});
|
||||
|
||||
it("timeout, answer, timeout stays online", async () => {
|
||||
reportRequestOutcome("reached-server");
|
||||
await timeOut();
|
||||
await answer();
|
||||
await timeOut();
|
||||
expect(getConnectivity()).toBe("online");
|
||||
});
|
||||
|
||||
it("a caller abort is never counted", async () => {
|
||||
reportRequestOutcome("reached-server");
|
||||
await timeOut();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
vi.stubGlobal("fetch", vi.fn(hangingFetch));
|
||||
const caller = new AbortController();
|
||||
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000, signal: caller.signal });
|
||||
const assertion = expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
caller.abort(new DOMException("weg", "AbortError"));
|
||||
await assertion;
|
||||
}
|
||||
expect(getConnectivity()).toBe("online");
|
||||
await timeOut();
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
});
|
||||
|
||||
it("a stalled body ends in TimeoutError and one such timeout does not flip the app offline", async () => {
|
||||
reportRequestOutcome("reached-server");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(stalledBodyResponse()));
|
||||
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
|
||||
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
await assertion;
|
||||
expect(getConnectivity()).toBe("online");
|
||||
});
|
||||
|
||||
it("a timed-out request does not clear an offline state set by a real network error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
await expect(fetchReportingOutcome("/x", { timeoutMs: 4_000 })).rejects.toBeInstanceOf(TypeError);
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
reportRequestOutcome("reached-server");
|
||||
vi.stubGlobal("fetch", vi.fn(hangingFetch));
|
||||
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
|
||||
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
await assertion;
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
|
||||
await fetchReportingOutcome("/x", { timeoutMs: 4_000 });
|
||||
expect(getConnectivity()).toBe("online");
|
||||
});
|
||||
|
||||
it("returns a readable response once headers and body arrived in time", async () => {
|
||||
|
||||
@@ -87,9 +87,9 @@ export function isNetworkError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
// fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including
|
||||
// 502/503/504 from a proxy) counts as "reached-server"; a timeout or network
|
||||
// TypeError counts as "network-error". A caller-initiated AbortError
|
||||
// (unmount/cancel) says nothing about the network and is not reported.
|
||||
// 502/503/504 from a proxy) counts as "reached-server" (clears offline at once);
|
||||
// a network TypeError counts as "network-error". A timeout or abort (our own
|
||||
// 4 s deadline, unmount, cancel) says nothing about the network: not reported.
|
||||
export async function fetchReportingOutcome(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit & { timeoutMs: number },
|
||||
@@ -102,8 +102,12 @@ export async function fetchReportingOutcome(
|
||||
reportRequestOutcome("reached-server");
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isNetworkError(error) && !(error instanceof DOMException && error.name === "AbortError")) {
|
||||
// A TypeError proves the network is gone. Our own timeout counts only when
|
||||
// repeated (see connectivity.ts); a caller abort says nothing.
|
||||
if (error instanceof TypeError) {
|
||||
reportRequestOutcome("network-error");
|
||||
} else if (error instanceof DOMException && error.name === "TimeoutError") {
|
||||
reportRequestOutcome("timeout");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Connectivity = "online" | "offline";
|
||||
export type RequestOutcome = "reached-server" | "network-error";
|
||||
export type RequestOutcome = "reached-server" | "network-error" | "timeout";
|
||||
|
||||
/** Consecutive timeouts without any HTTP answer that count as offline. */
|
||||
export const TIMEOUTS_BEFORE_OFFLINE = 2;
|
||||
|
||||
// Offline = the browser says so, or the last request failed at network level
|
||||
// and no request has reached the server since. navigator.onLine alone is
|
||||
// unreliable (it stays true on a captive portal or a dead uplink).
|
||||
let lastOutcome: RequestOutcome = "reached-server";
|
||||
let lastOutcome: "reached-server" | "network-error" = "reached-server";
|
||||
let consecutiveTimeouts = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
@@ -14,6 +18,14 @@ function notify(): void {
|
||||
}
|
||||
|
||||
export function reportRequestOutcome(outcome: RequestOutcome): void {
|
||||
if (outcome === "timeout") {
|
||||
// One slow request proves nothing; two in a row without an answer do.
|
||||
consecutiveTimeouts += 1;
|
||||
if (consecutiveTimeouts < TIMEOUTS_BEFORE_OFFLINE) return;
|
||||
outcome = "network-error";
|
||||
} else {
|
||||
consecutiveTimeouts = 0;
|
||||
}
|
||||
if (outcome === lastOutcome) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,4 +55,6 @@ export interface PhenologyState {
|
||||
gts: number | null;
|
||||
gtsThroughDay: string | null;
|
||||
note: SeasonNote;
|
||||
/** Days between inspections now; null in winter or without the interval rule. */
|
||||
intervalDays: number | null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DOMAIN_EVENT_LABELS,
|
||||
@@ -55,3 +57,20 @@ describe("system labels", () => {
|
||||
expect(formatAge(86400)).toBe("vor 1 Tag");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOMAIN_EVENT_LABELS completeness", () => {
|
||||
it("names PhenologyRecomputeRequested and WinterCheckRecorded", () => {
|
||||
expect(DOMAIN_EVENT_LABELS.PhenologyRecomputeRequested).toBe("Saison neu berechnen angefordert");
|
||||
expect(DOMAIN_EVENT_LABELS.WinterCheckRecorded).toBe("Winterkontrolle erfasst");
|
||||
});
|
||||
|
||||
it("has a German label for every backend domain event type", () => {
|
||||
const catalog = resolve(process.cwd(), "../ApiCore-Backend/src/domain-events/domain-event-types.ts");
|
||||
if (!existsSync(catalog)) return; // frontend checked out alone
|
||||
const source = readFileSync(catalog, "utf8");
|
||||
const block = source.slice(source.indexOf("const CATALOG"), source.indexOf("DOMAIN_EVENT_TYPES"));
|
||||
const types = [...block.matchAll(/^\s+([A-Z][A-Za-z]+): true,/gm)].map((match) => match[1]);
|
||||
expect(types.length).toBeGreaterThan(20);
|
||||
expect(types.filter((type) => !(type in DOMAIN_EVENT_LABELS))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ export const DOMAIN_EVENT_LABELS: Record<string, string> = {
|
||||
HoneyBatchCorrected: "Honigcharge korrigiert",
|
||||
PhenologyReported: "Pflanzenmeldung erfasst",
|
||||
PhenologyCorrected: "Pflanzenmeldung korrigiert",
|
||||
PhenologyRecomputeRequested: "Saison neu berechnen angefordert",
|
||||
WinterCheckRecorded: "Winterkontrolle erfasst",
|
||||
SeasonPhaseChanged: "Saisonabschnitt gewechselt",
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user