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