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.");
|
||||
|
||||
Reference in new issue
Block a user