feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { summarizeQueue } from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog";
|
||||
|
||||
const logoutMock = vi.fn();
|
||||
let sessionMode: "online" | "offline" = "online";
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ logout: logoutMock, sessionMode }),
|
||||
}));
|
||||
vi.mock("@/lib/offline/db", () => ({ summarizeQueue: vi.fn() }));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
|
||||
const NOTHING_WAITS = { own: 0, failed: 0, foreign: 0 };
|
||||
const onOpenChange = vi.fn();
|
||||
const onLoggedOut = vi.fn();
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<LogoutConfirmDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
userId="user-1"
|
||||
onLoggedOut={onLoggedOut}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LogoutConfirmDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logoutMock.mockResolvedValue(undefined);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue(NOTHING_WAITS);
|
||||
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
reportRequestOutcome("reached-server");
|
||||
sessionMode = "online";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reportRequestOutcome("reached-server");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("logs out directly when nothing waits", async () => {
|
||||
renderDialog();
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks before deleting unsent entries", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 1, foreign: 5 });
|
||||
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
// Waiting and rejected entries of the person count; other accounts' do not.
|
||||
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Erst senden" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Abmelden und Einträge löschen" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("deletes the entries only after the destructive button was pressed", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
|
||||
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("cancel leaves everything as it is", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends first and logs out once everything is gone", async () => {
|
||||
vi.mocked(summarizeQueue)
|
||||
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
|
||||
.mockResolvedValue(NOTHING_WAITS);
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
|
||||
});
|
||||
|
||||
it("stays open and says how many still wait when sending was not enough", async () => {
|
||||
vi.mocked(summarizeQueue)
|
||||
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
|
||||
.mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog", { name: "Abmelden?" })).toHaveTextContent(
|
||||
"1 Eintrag ist noch nicht gesendet.",
|
||||
),
|
||||
);
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no sending without network and warns that signing out deletes the entries", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
|
||||
expect(dialog).toHaveTextContent("Ohne Netz");
|
||||
});
|
||||
|
||||
it("offline logout marks the logout as pending (through the auth context)", async () => {
|
||||
// The pending mark is the auth context's job; the dialog only asks for the logout.
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
|
||||
});
|
||||
|
||||
it("shows an error and stays open when the logout fails", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
logoutMock.mockRejectedValue(new Error("boom"));
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
|
||||
expect(onLoggedOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing while it is closed", () => {
|
||||
render(
|
||||
<LogoutConfirmDialog
|
||||
open={false}
|
||||
onOpenChange={onOpenChange}
|
||||
userId="user-1"
|
||||
onLoggedOut={onLoggedOut}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(summarizeQueue).not.toHaveBeenCalled();
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no sending in an offline session, even when the network looks fine", async () => {
|
||||
sessionMode = "offline";
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
|
||||
expect(dialog).toHaveTextContent("Ohne Netz");
|
||||
});
|
||||
|
||||
it("shows the error with a retry when the direct logout of an empty queue fails", async () => {
|
||||
logoutMock.mockRejectedValueOnce(new Error("boom"));
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
|
||||
expect(dialog).not.toHaveTextContent("noch nicht gesendet");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(2));
|
||||
expect(logoutMock).toHaveBeenLastCalledWith({ discardQueue: false });
|
||||
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { summarizeQueue } from "@/lib/offline/db";
|
||||
import { entriesNotSent } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
|
||||
interface LogoutConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userId: string;
|
||||
/** The person is signed out: the caller moves on to the login page. */
|
||||
onLoggedOut: () => void;
|
||||
}
|
||||
|
||||
/** Waiting and rejected entries of the person; owner-less legacy ones count as theirs. */
|
||||
async function countUnsent(userId: string): Promise<number> {
|
||||
try {
|
||||
const { own, failed } = await summarizeQueue(userId);
|
||||
return own + failed;
|
||||
} catch {
|
||||
// The queue cannot be read, so it cannot be lost either.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing out deletes the person's unsent entries: on a shared phone they must
|
||||
* not be sent under the next account. So with entries waiting it asks first
|
||||
* (send them, delete them, or stay); with nothing waiting it signs out at once.
|
||||
* Renders nothing while closed.
|
||||
*/
|
||||
export function LogoutConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
userId,
|
||||
onLoggedOut,
|
||||
}: LogoutConfirmDialogProps) {
|
||||
const { logout, sessionMode } = useAuth();
|
||||
const connectivity = useConnectivity();
|
||||
const [unsent, setUnsent] = useState<number | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
|
||||
// Closed: forget what the last opening found (adjusted while rendering).
|
||||
if (wasOpen !== open) {
|
||||
setWasOpen(open);
|
||||
if (!open) {
|
||||
setUnsent(null);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut(discardQueue: boolean) {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await logout({ discardQueue });
|
||||
onOpenChange(false);
|
||||
onLoggedOut();
|
||||
} catch {
|
||||
setError("Abmelden hat nicht geklappt. Bitte noch einmal versuchen.");
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let isCurrent = true;
|
||||
void countUnsent(userId).then((count) => {
|
||||
if (!isCurrent) {
|
||||
return;
|
||||
}
|
||||
if (count === 0) {
|
||||
void signOut(false);
|
||||
} else {
|
||||
setUnsent(count);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
// signOut only reads props that are part of this effect's trigger.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, userId]);
|
||||
|
||||
async function sendFirst() {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await syncPendingEntries(userId);
|
||||
} catch {
|
||||
// Whatever could not be sent is counted below.
|
||||
}
|
||||
const remaining = await countUnsent(userId);
|
||||
if (remaining === 0) {
|
||||
await signOut(false);
|
||||
return;
|
||||
}
|
||||
setUnsent(remaining);
|
||||
setIsBusy(false);
|
||||
}
|
||||
|
||||
// An offline session has no token: nothing can be sent, whatever the network says.
|
||||
const isOnline = connectivity === "online" && sessionMode !== "offline";
|
||||
const hasEntries = unsent !== null && unsent > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Abmelden?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasEntries
|
||||
? `${entriesNotSent(unsent)} ${
|
||||
isOnline
|
||||
? "Sende sie zuerst, oder melde dich ab und lösche sie."
|
||||
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
|
||||
}`
|
||||
: "Du bist noch angemeldet."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={isBusy}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
{hasEntries && isOnline ? (
|
||||
<Button type="button" size="touch" disabled={isBusy} onClick={() => void sendFirst()}>
|
||||
Erst senden
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
disabled={isBusy}
|
||||
onClick={() => void signOut(hasEntries)}
|
||||
>
|
||||
{hasEntries ? "Abmelden und Einträge löschen" : "Erneut versuchen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ColonySelectField, LivingColonySelectField } from "./colony-select-field";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import type { ColonyStatus } from "@/lib/colonies/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function colony(id: string, code: string, status: ColonyStatus) {
|
||||
return { id, code, status, apiaryId: "apiary-1" };
|
||||
@@ -44,9 +52,73 @@ async function openOptions() {
|
||||
return screen.findAllByRole("option");
|
||||
}
|
||||
|
||||
async function openOptionsOf(index: number) {
|
||||
await userEvent.click(screen.getAllByRole("combobox")[index]);
|
||||
return screen.findAllByRole("option");
|
||||
}
|
||||
|
||||
describe("LivingColonySelectField", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("offers the pack's living colonies while offline", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(listPacks).mockResolvedValue([
|
||||
storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
colonies: [packColony("p-2", "B2"), packColony("p-1", "B1", { status: "WEAK" })],
|
||||
}),
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-2",
|
||||
colonies: [packColony("p-3", "C1", { status: "DISSOLVED" }), packColony("p-4", "C2")],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Offline: nur Völker der geladenen Stände."),
|
||||
).toBeInTheDocument();
|
||||
const options = await openOptions();
|
||||
expect(options.map((option) => option.textContent)).toEqual(["B1 (Schwach)", "B2", "C2"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("tells how to work offline when no apiary was loaded for offline use", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Kein Netz. Lade den Stand vorher mit „Für offline laden“."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shares the one colony request with every other colony list on the page", async () => {
|
||||
vi.mocked(fetchColonies).mockClear();
|
||||
withClient(
|
||||
<>
|
||||
<LivingHarness />
|
||||
<AllHarness initial="" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Volk wählen"));
|
||||
await openOptionsOf(0);
|
||||
expect(fetchColonies).toHaveBeenCalledTimes(1);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never offers pack colonies when the server refused the list", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new ApiError(403, "Forbidden"));
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(await screen.findByText(/Völker konnten nicht geladen werden/)).toBeInTheDocument();
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers weak, queenless and conspicuous colonies - not only active ones", async () => {
|
||||
|
||||
@@ -5,25 +5,89 @@ import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
listPacks,
|
||||
livingColoniesFromPacks,
|
||||
} from "@/lib/apiaries/offline-pack";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
|
||||
const PLACEHOLDER = "Volk wählen";
|
||||
const LOADING_PLACEHOLDER = "Völker werden geladen…";
|
||||
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
|
||||
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
|
||||
|
||||
/** What a colony choice needs - a server colony or one from an offline pack. */
|
||||
type ColonyChoice = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
|
||||
|
||||
function useColonies() {
|
||||
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
}
|
||||
|
||||
type ChoicesHint = "load-error" | "offline-not-loaded" | "from-packs";
|
||||
|
||||
const HINT_TEXTS: Record<ChoicesHint, string> = {
|
||||
"load-error": LOAD_ERROR_HINT,
|
||||
"offline-not-loaded": OFFLINE_NOT_LOADED_HINT,
|
||||
"from-packs": OFFLINE_PACK_HINT,
|
||||
};
|
||||
|
||||
interface LivingColonyChoices {
|
||||
colonies: readonly ColonyChoice[] | undefined;
|
||||
isLoading: boolean;
|
||||
hint: ChoicesHint | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every colony from the shared ["colonies"] query - one request for every
|
||||
* colony list on the page. Only when that failed for lack of network (never
|
||||
* on a server refusal) the living colonies of the user's offline packs step
|
||||
* in, from their own query: pack colonies are partial records and never go
|
||||
* into the shared cache.
|
||||
*/
|
||||
function useLivingColonyChoices(): LivingColonyChoices {
|
||||
const userId = useCurrentUserId();
|
||||
const server = useColonies();
|
||||
// failureReason: offline the retry pauses, so `error` stays unset.
|
||||
const isOffline =
|
||||
server.data === undefined && isNetworkError(server.error ?? server.failureReason);
|
||||
const packs = useQuery({
|
||||
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "living-colonies"],
|
||||
queryFn: async () => livingColoniesFromPacks(await listPacks(userId ?? "")),
|
||||
enabled: isOffline && userId !== null,
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
|
||||
if (!isOffline) {
|
||||
return {
|
||||
colonies: server.data,
|
||||
isLoading: server.isLoading,
|
||||
hint: server.isError && server.data === undefined ? "load-error" : null,
|
||||
};
|
||||
}
|
||||
const packColonies = packs.data && packs.data.length > 0 ? packs.data : undefined;
|
||||
let hint: ChoicesHint | null = "offline-not-loaded";
|
||||
if (packColonies) {
|
||||
hint = "from-packs";
|
||||
} else if (packs.isLoading) {
|
||||
hint = null;
|
||||
}
|
||||
return { colonies: packColonies, isLoading: packs.isLoading, hint };
|
||||
}
|
||||
|
||||
interface ColonyChoicesProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
/** The colonies on offer; `undefined` while loading. */
|
||||
options: readonly Colony[] | undefined;
|
||||
options: readonly ColonyChoice[] | undefined;
|
||||
/** Every known colony, so a chosen one is named even if it is not offered. */
|
||||
colonies: readonly Colony[] | undefined;
|
||||
colonies: readonly ColonyChoice[] | undefined;
|
||||
isLoading: boolean;
|
||||
dataField?: string;
|
||||
describedBy?: string;
|
||||
@@ -65,6 +129,9 @@ interface LivingColonySelectFieldProps {
|
||||
* Weak, conspicuous and queenless colonies are included - they need the most
|
||||
* attention - and none that has ended. A colony handed in by a link that has
|
||||
* ended since is still named, just not offered.
|
||||
*
|
||||
* Without network it offers the living colonies of the apiaries loaded for
|
||||
* offline use ("Für offline laden") and says so.
|
||||
*/
|
||||
export function LivingColonySelectField({
|
||||
triggerId,
|
||||
@@ -76,18 +143,25 @@ export function LivingColonySelectField({
|
||||
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
|
||||
dataField = "colonyId",
|
||||
}: LivingColonySelectFieldProps) {
|
||||
const { data: colonies, isLoading, isError } = useColonies();
|
||||
const { colonies, isLoading, hint: choicesHint } = useLivingColonyChoices();
|
||||
const living = colonies?.filter(
|
||||
(colony) => isLivingColony(colony) && colony.id !== excludeId,
|
||||
);
|
||||
|
||||
function hint(): string | undefined {
|
||||
if (choicesHint) {
|
||||
return HINT_TEXTS[choicesHint];
|
||||
}
|
||||
return living?.length === 0 ? emptyHint : undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
id={triggerId}
|
||||
label={label}
|
||||
required
|
||||
error={error}
|
||||
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
|
||||
hint={hint()}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<ColonyChoices
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BulkColony } from "@/lib/bulk/use-apiary-colonies";
|
||||
|
||||
interface BulkSubmitNoteProps {
|
||||
submitError: string | null;
|
||||
isRetryLater: boolean;
|
||||
/** Colonies the server refused, by id. */
|
||||
invalidColonyIds: readonly string[];
|
||||
/** The colonies on offer, to name the refused ones by code. */
|
||||
colonies: readonly Pick<BulkColony, "id" | "code">[] | undefined;
|
||||
isPending: boolean;
|
||||
/** Takes the refused colonies out of the selection and saves again. */
|
||||
onResendWithout: () => void;
|
||||
}
|
||||
|
||||
function codesOf(
|
||||
ids: readonly string[],
|
||||
colonies: BulkSubmitNoteProps["colonies"],
|
||||
): string[] {
|
||||
return ids.flatMap((id) => colonies?.find((colony) => colony.id === id)?.code ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* What stands above the save button of a bulk capture. A refusal names the
|
||||
* colonies by code and offers „Ohne … senden“; „wird bereits verarbeitet“ is
|
||||
* only a request to wait. Null when there is nothing to say.
|
||||
*/
|
||||
export function BulkSubmitNote({
|
||||
submitError,
|
||||
isRetryLater,
|
||||
invalidColonyIds,
|
||||
colonies,
|
||||
isPending,
|
||||
onResendWithout,
|
||||
}: BulkSubmitNoteProps) {
|
||||
if (isRetryLater) {
|
||||
return (
|
||||
<p role="status" className="text-sm font-medium">
|
||||
Wird gerade verarbeitet. Bitte gleich noch einmal speichern.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!submitError) {
|
||||
return null;
|
||||
}
|
||||
const codes = codesOf(invalidColonyIds, colonies);
|
||||
const hasRefused = invalidColonyIds.length > 0;
|
||||
return (
|
||||
<>
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{submitError}
|
||||
{codes.length > 0 ? `: ${codes.join(", ")}` : ""}
|
||||
</p>
|
||||
{hasRefused ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={isPending}
|
||||
onClick={onResendWithout}
|
||||
>
|
||||
{codes.length > 0 ? `Ohne ${codes.join(", ")} senden` : "Ohne diese Völker senden"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { CaptureTarget, CaptureTargetToggle } from "@/components/form/capture-target-toggle";
|
||||
import { ColonyMultiSelect } from "@/components/form/colony-multi-select";
|
||||
|
||||
interface CaptureColonyFieldProps {
|
||||
/** Prefix of the element ids, e.g. "treatment". */
|
||||
idPrefix: string;
|
||||
target: CaptureTarget;
|
||||
onTargetChange: (target: CaptureTarget) => void;
|
||||
colonyId: string;
|
||||
onColonyChange: (colonyId: string) => void;
|
||||
colonyError?: string | null;
|
||||
bulkApiaryId: string | null;
|
||||
onBulkApiaryChange: (apiaryId: string | null) => void;
|
||||
bulkColonyIds: readonly string[];
|
||||
onBulkColonyIdsChange: (ids: string[]) => void;
|
||||
bulkError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The „Für“ choice of a capture form and what it needs: the single colony
|
||||
* select, or the stand with its colonies for the same entry on several.
|
||||
*/
|
||||
export function CaptureColonyField({
|
||||
idPrefix,
|
||||
target,
|
||||
onTargetChange,
|
||||
colonyId,
|
||||
onColonyChange,
|
||||
colonyError,
|
||||
bulkApiaryId,
|
||||
onBulkApiaryChange,
|
||||
bulkColonyIds,
|
||||
onBulkColonyIdsChange,
|
||||
bulkError,
|
||||
}: CaptureColonyFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<CaptureTargetToggle value={target} onChange={onTargetChange} />
|
||||
{target === "single" ? (
|
||||
<LivingColonySelectField
|
||||
triggerId={`${idPrefix}-colony`}
|
||||
value={colonyId}
|
||||
onChange={onColonyChange}
|
||||
error={colonyError}
|
||||
/>
|
||||
) : (
|
||||
<ColonyMultiSelect
|
||||
id={`${idPrefix}-colonies`}
|
||||
apiaryId={bulkApiaryId}
|
||||
onApiaryChange={onBulkApiaryChange}
|
||||
selectedIds={bulkColonyIds}
|
||||
onChange={onBulkColonyIdsChange}
|
||||
error={bulkError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { ChoiceGroup, ChoiceOption } from "@/components/form/choice-group";
|
||||
|
||||
/** One colony, or the same capture for several colonies at once. */
|
||||
export type CaptureTarget = "single" | "bulk";
|
||||
|
||||
const TARGET_OPTIONS: ChoiceOption<CaptureTarget>[] = [
|
||||
{ value: "single", label: "Ein Volk" },
|
||||
{ value: "bulk", label: "Mehrere Völker" },
|
||||
];
|
||||
|
||||
interface CaptureTargetToggleProps {
|
||||
value: CaptureTarget;
|
||||
onChange: (target: CaptureTarget) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Für: Ein Volk / Mehrere Völker“ at the top of a capture form. Always one
|
||||
* of the two, so it is not marked as required or optional.
|
||||
*/
|
||||
export function CaptureTargetToggle({ value, onChange }: CaptureTargetToggleProps) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={labelId} className="text-sm font-medium">
|
||||
Für
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
labelledBy={labelId}
|
||||
options={TARGET_OPTIONS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ColonyMultiSelect } from "./colony-multi-select";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import type { ColonyStatus } from "@/lib/colonies/types";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function colony(id: string, code: string, status: ColonyStatus, apiaryId = "apiary-1") {
|
||||
return { id, code, status, apiaryId };
|
||||
}
|
||||
|
||||
const COLONIES = [
|
||||
colony("c-1", "V-01", "ACTIVE"),
|
||||
colony("c-2", "V-02", "WEAK"),
|
||||
colony("c-3", "V-03", "QUEENLESS"),
|
||||
colony("c-4", "V-04", "DISSOLVED"),
|
||||
colony("c-5", "W-01", "ACTIVE", "apiary-2"),
|
||||
];
|
||||
|
||||
function Harness({
|
||||
initialApiaryId = null,
|
||||
initialIds = [],
|
||||
error,
|
||||
onChangeSpy,
|
||||
}: {
|
||||
initialApiaryId?: string | null;
|
||||
initialIds?: string[];
|
||||
error?: string | null;
|
||||
onChangeSpy?: (ids: string[]) => void;
|
||||
}) {
|
||||
const [apiaryId, setApiaryId] = useState<string | null>(initialApiaryId);
|
||||
const [ids, setIds] = useState<string[]>(initialIds);
|
||||
return (
|
||||
<ColonyMultiSelect
|
||||
id="bulk-colonies"
|
||||
apiaryId={apiaryId}
|
||||
onApiaryChange={setApiaryId}
|
||||
selectedIds={ids}
|
||||
onChange={(next) => {
|
||||
onChangeSpy?.(next);
|
||||
setIds(next);
|
||||
}}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSelect(props: Parameters<typeof Harness>[0] = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Harness {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseApiary(name: string) {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name }));
|
||||
}
|
||||
|
||||
describe("ColonyMultiSelect", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchApiaries)
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
{ id: "apiary-1", name: "Waldrand" },
|
||||
{ id: "apiary-2", name: "Obstwiese" },
|
||||
] as never);
|
||||
vi.mocked(fetchColonies)
|
||||
.mockReset()
|
||||
.mockImplementation((async (filter?: { apiaryId?: string }) =>
|
||||
COLONIES.filter((item) => item.apiaryId === filter?.apiaryId)) as never);
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("choosing an apiary preselects all its living colonies", async () => {
|
||||
renderSelect();
|
||||
|
||||
await chooseApiary("Waldrand");
|
||||
|
||||
const boxes = await screen.findAllByRole("checkbox");
|
||||
expect(boxes.map((box) => box.getAttribute("aria-checked"))).toEqual(["true", "true", "true"]);
|
||||
// The weak and the queenless colony are in, the dissolved one never appears.
|
||||
expect(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "V-03 (Weisellos)" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: /V-04/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: /W-01/ })).not.toBeInTheDocument();
|
||||
expect(fetchColonies).toHaveBeenCalledWith({ apiaryId: "apiary-1" });
|
||||
expect(await screen.findByText("3 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deselecting one keeps the rest", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderSelect({ onChangeSpy: onChange });
|
||||
await chooseApiary("Waldrand");
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(["c-1", "c-3"]);
|
||||
expect(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("checkbox", { name: "V-01" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText("2 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rows are 56 px and checkboxes are named by colony code", async () => {
|
||||
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-1"] });
|
||||
|
||||
const box = await screen.findByRole("checkbox", { name: "V-01" });
|
||||
|
||||
expect(box.closest("label")).toHaveClass("min-h-14");
|
||||
expect(screen.getAllByRole("checkbox").map((item) => item.closest("label"))).toSatisfy(
|
||||
(rows: (HTMLElement | null)[]) => rows.every((row) => row?.classList.contains("min-h-14")),
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles a colony when its row text is tapped", async () => {
|
||||
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-2"] });
|
||||
await screen.findByRole("checkbox", { name: "V-01" });
|
||||
|
||||
await userEvent.click(screen.getByText("V-01"));
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: "V-01" })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("Alle abwählen and Alle wählen set the whole apiary at once", async () => {
|
||||
renderSelect();
|
||||
await chooseApiary("Waldrand");
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alle abwählen" }));
|
||||
expect(screen.getByText("0 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alle wählen" }));
|
||||
expect(screen.getByText("3 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a restored selection instead of preselecting everything", async () => {
|
||||
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-3"] });
|
||||
|
||||
expect(await screen.findByText("1 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "V-03 (Weisellos)" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("preselects the colonies of the other apiary after switching", async () => {
|
||||
renderSelect();
|
||||
await chooseApiary("Waldrand");
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
|
||||
await chooseApiary("Obstwiese");
|
||||
|
||||
expect(await screen.findByRole("checkbox", { name: "W-01" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByRole("checkbox", { name: "V-01" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1 von 1 Volk gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the loaded packs while offline", async () => {
|
||||
vi.mocked(fetchApiaries).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(listPacks).mockResolvedValue([
|
||||
storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
apiaryName: "Waldrand",
|
||||
colonies: [packColony("p-1", "B1"), packColony("p-2", "B2", { status: "DISSOLVED" })],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
renderSelect();
|
||||
|
||||
await chooseApiary("Waldrand");
|
||||
|
||||
expect(await screen.findByRole("checkbox", { name: "B1" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByRole("checkbox", { name: "B2" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Offline: nur Völker der geladenen Stände.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells how to work offline when no apiary was loaded", async () => {
|
||||
vi.mocked(fetchApiaries).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderSelect();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Kein Netz. Lade den Stand vorher mit „Für offline laden“."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the error next to the list", async () => {
|
||||
renderSelect({ error: "Bitte mindestens ein Volk wählen." });
|
||||
|
||||
expect(await screen.findByText("Bitte mindestens ein Volk wählen.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the limit when more than 100 colonies are selected", async () => {
|
||||
const many = Array.from({ length: 101 }, (_, index) =>
|
||||
colony(`c-${index}`, `V-${index}`, "ACTIVE"),
|
||||
);
|
||||
vi.mocked(fetchColonies).mockResolvedValue(many as never);
|
||||
renderSelect();
|
||||
|
||||
await chooseApiary("Waldrand");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Höchstens 100 Völker auf einmal.")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText("101 von 101 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { BULK_MAX_COLONIES } from "@/lib/bulk/api";
|
||||
import { useApiaryChoices, useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
|
||||
const APIARY_PLACEHOLDER = "Stand wählen";
|
||||
const LOADING_PLACEHOLDER = "Stände werden geladen…";
|
||||
const LOAD_ERROR_HINT = "Stände konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
const COLONIES_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
|
||||
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
|
||||
const NO_COLONIES_HINT = "In diesem Stand gibt es noch keine aktiven Völker.";
|
||||
const MAX_COLONIES_ERROR = `Höchstens ${BULK_MAX_COLONIES} Völker auf einmal.`;
|
||||
|
||||
export interface ColonyMultiSelectProps {
|
||||
/** Base id; the stand select carries `${id}-apiary`. */
|
||||
id: string;
|
||||
apiaryId: string | null;
|
||||
onApiaryChange: (apiaryId: string | null) => void;
|
||||
selectedIds: readonly string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function countText(selected: number, total: number): string {
|
||||
return `${selected} von ${total} ${total === 1 ? "Volk" : "Völkern"} gewählt`;
|
||||
}
|
||||
|
||||
function errorHint(error: unknown, noun: "Stände" | "Völker"): string {
|
||||
if (isNetworkError(error)) return OFFLINE_NOT_LOADED_HINT;
|
||||
return noun === "Stände" ? LOAD_ERROR_HINT : COLONIES_ERROR_HINT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the colonies one capture is recorded for: first the stand, which
|
||||
* selects all its living colonies at once (ended ones never appear), then
|
||||
* single colonies can be taken out. Like the single-colony select it reads
|
||||
* the loaded offline packs when there is no network.
|
||||
*/
|
||||
export function ColonyMultiSelect({
|
||||
id,
|
||||
apiaryId,
|
||||
onApiaryChange,
|
||||
selectedIds,
|
||||
onChange,
|
||||
error,
|
||||
}: ColonyMultiSelectProps) {
|
||||
const apiaries = useApiaryChoices();
|
||||
const colonies = useApiaryColonies(apiaryId);
|
||||
const groupLabelId = useId();
|
||||
const list = colonies.data?.items;
|
||||
|
||||
// The stand a selection was made or restored for: only a different stand
|
||||
// preselects again, so a restored draft keeps its own choice.
|
||||
const preselectedFor = useRef<string | null>(selectedIds.length > 0 ? apiaryId : null);
|
||||
useEffect(() => {
|
||||
if (!apiaryId || !list) return;
|
||||
if (preselectedFor.current !== apiaryId) {
|
||||
preselectedFor.current = apiaryId;
|
||||
onChange(list.map((colony) => colony.id));
|
||||
return;
|
||||
}
|
||||
// A colony that is no longer on offer (ended since a draft) is not sent.
|
||||
const offered = new Set(list.map((colony) => colony.id));
|
||||
const kept = selectedIds.filter((colonyId) => offered.has(colonyId));
|
||||
if (kept.length !== selectedIds.length) {
|
||||
onChange(kept);
|
||||
}
|
||||
}, [apiaryId, list, selectedIds, onChange]);
|
||||
|
||||
function hint(): string | undefined {
|
||||
if (apiaries.isError) return errorHint(apiaries.error, "Stände");
|
||||
if (apiaryId && colonies.isError) return errorHint(colonies.error, "Völker");
|
||||
if (apiaries.data?.source === "packs" || colonies.data?.source === "packs") {
|
||||
return OFFLINE_PACK_HINT;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offered = new Set((list ?? []).map((colony) => colony.id));
|
||||
const selectedCount = selectedIds.filter((colonyId) => offered.has(colonyId)).length;
|
||||
const shownError = selectedIds.length > BULK_MAX_COLONIES ? MAX_COLONIES_ERROR : error;
|
||||
|
||||
function toggle(colonyId: string, isChecked: boolean) {
|
||||
const next = isChecked
|
||||
? [...selectedIds, colonyId]
|
||||
: selectedIds.filter((selectedId) => selectedId !== colonyId);
|
||||
// Keep the order of the list, not the order of tapping.
|
||||
onChange((list ?? []).map((colony) => colony.id).filter((item) => next.includes(item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field id={`${id}-apiary`} label="Stand" required hint={hint()}>
|
||||
{({ id: triggerId, describedBy, invalid }) => (
|
||||
<RecordSelect
|
||||
id={triggerId}
|
||||
value={apiaryId ?? ""}
|
||||
onChange={(next) => onApiaryChange(next === "" ? null : next)}
|
||||
items={apiaries.data?.items}
|
||||
dataField="bulkColonies"
|
||||
getLabel={(apiary) => apiary.name}
|
||||
placeholder={apiaries.isLoading ? LOADING_PLACEHOLDER : APIARY_PLACEHOLDER}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{list ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={groupLabelId}
|
||||
aria-describedby={shownError ? `${id}-error` : undefined}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span id={groupLabelId} className="text-sm font-medium">
|
||||
Völker
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => onChange(list.map((colony) => colony.id))}
|
||||
>
|
||||
Alle wählen
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => onChange([])}>
|
||||
Alle abwählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{NO_COLONIES_HINT}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((colony) => (
|
||||
<li key={colony.id}>
|
||||
<label
|
||||
htmlFor={`${id}-${colony.id}`}
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${id}-${colony.id}`}
|
||||
className="size-6"
|
||||
aria-labelledby={`${id}-${colony.id}-label`}
|
||||
checked={selectedIds.includes(colony.id)}
|
||||
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
|
||||
/>
|
||||
<span id={`${id}-${colony.id}-label`} className="text-base">
|
||||
{colonyOptionLabel(colony)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{countText(selectedCount, list.length)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{shownError ? (
|
||||
<p id={`${id}-error`} role="alert" className="text-sm font-medium text-destructive">
|
||||
{shownError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { deleteAllPacks, getPack } from "@/lib/apiaries/offline-pack";
|
||||
import { DAY_MS, offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import { LoadPackButton } from "./load-pack-button";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const postMessage = vi.fn();
|
||||
|
||||
function renderButton() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<LoadPackButton apiaryId="apiary-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoadPackButton", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
postMessage.mockReset();
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: { ready: Promise.resolve({ active: { postMessage } }) },
|
||||
});
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
it("loads the apiary for offline use and then says how long ago", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
expect(await screen.findByText("Geladen vor weniger als einer Minute")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Geladen vor weniger als einer Minute");
|
||||
expect((await getPack("apiary-1"))?.ownerUserId).toBe("user-1");
|
||||
// The app shell is refreshed too, so the offline pages open without network.
|
||||
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith({ type: "WARM_SHELL" }));
|
||||
});
|
||||
|
||||
it("asks once that this device's offline data is kept, not evicted", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
const persisted = vi.fn(async () => false);
|
||||
Object.defineProperty(navigator, "storage", {
|
||||
configurable: true,
|
||||
value: { persist, persisted },
|
||||
});
|
||||
try {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(1));
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "storage");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not ask again when the storage is already kept", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
Object.defineProperty(navigator, "storage", {
|
||||
configurable: true,
|
||||
value: { persist, persisted: vi.fn(async () => true) },
|
||||
});
|
||||
try {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await screen.findByText("Geladen vor weniger als einer Minute");
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "storage");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the stale hint for a pack older than 24 h", async () => {
|
||||
const db = await getDb();
|
||||
await db.put(
|
||||
"offline-packs",
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
|
||||
savedAt: new Date(Date.now() - 3 * DAY_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
renderButton();
|
||||
|
||||
expect(await screen.findByText("Geladen vor 3 Tagen")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Stand-Daten von .* – bei Netz neu laden$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats another account's pack as not loaded", async () => {
|
||||
const db = await getDb();
|
||||
await db.put(
|
||||
"offline-packs",
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
renderButton();
|
||||
|
||||
await screen.findByRole("button", { name: "Für offline laden" });
|
||||
expect(screen.queryByText(/^Geladen vor/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when there is no network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Kein Netz – der Stand konnte nicht geladen werden."),
|
||||
);
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a server refusal differently from a missing network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new ApiError(403, "Forbidden"));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Stand konnte nicht für offline geladen werden. Bitte noch einmal versuchen.",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
getOwnPack,
|
||||
isPackStale,
|
||||
loadPack,
|
||||
ownPackQueryKey,
|
||||
packAgeText,
|
||||
packDayText,
|
||||
} from "@/lib/apiaries/offline-pack";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
|
||||
const NO_NETWORK_TEXT = "Kein Netz – der Stand konnte nicht geladen werden.";
|
||||
const LOAD_FAILED_TEXT = "Stand konnte nicht für offline geladen werden. Bitte noch einmal versuchen.";
|
||||
|
||||
interface PackAgeNoteProps {
|
||||
stored: StoredPack;
|
||||
/** When the pack was read from storage (the query's `dataUpdatedAt`). */
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the pack was loaded, and from 24 h on that its data is old - in words
|
||||
* and with an icon, never by colour alone.
|
||||
*/
|
||||
export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
|
||||
{isPackStale(stored, now) ? (
|
||||
<p className="flex items-center gap-1.5 font-medium">
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
||||
{`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks the service worker to refresh the app shell, so the offline pages open without network. */
|
||||
function requestShellWarmUp(): void {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
void navigator.serviceWorker.ready
|
||||
.then((registration) => registration.active?.postMessage({ type: "WARM_SHELL" }))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the browser once to keep this site's storage (packs, the offline
|
||||
* queue) instead of evicting it under storage pressure. Best effort.
|
||||
*/
|
||||
function requestPersistentStorage(): void {
|
||||
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
|
||||
if (!storage?.persist || !storage.persisted) {
|
||||
return;
|
||||
}
|
||||
void storage
|
||||
.persisted()
|
||||
.then((isPersisted) => (isPersisted ? undefined : storage.persist()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** The pack of this apiary, if the signed-in user loaded it. */
|
||||
export function useOwnPack(apiaryId: string, userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ownPackQueryKey(userId, apiaryId),
|
||||
queryFn: () => getOwnPack(apiaryId, userId ?? ""),
|
||||
enabled: userId !== null && apiaryId !== "",
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* „Für offline laden“: keeps the apiary's colonies, queens, last inspections
|
||||
* and open tasks on this device for work without reception, and says how
|
||||
* old what is kept is.
|
||||
*/
|
||||
export function LoadPackButton({ apiaryId }: { apiaryId: string }) {
|
||||
const userId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
const isOffline = useConnectivity() === "offline";
|
||||
const { data: stored, dataUpdatedAt } = useOwnPack(apiaryId, userId);
|
||||
|
||||
const load = useMutation({
|
||||
mutationFn: (ownerUserId: string) => loadPack(apiaryId, ownerUserId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_PACKS_QUERY_KEY });
|
||||
requestShellWarmUp();
|
||||
requestPersistentStorage();
|
||||
},
|
||||
onError: (error) => toast.error(isNetworkError(error) ? NO_NETWORK_TEXT : LOAD_FAILED_TEXT),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={userId === null || isOffline || load.isPending}
|
||||
onClick={() => {
|
||||
if (userId) load.mutate(userId);
|
||||
}}
|
||||
>
|
||||
<Download aria-hidden />
|
||||
{load.isPending ? "Wird geladen…" : "Für offline laden"}
|
||||
</Button>
|
||||
{/* Always mounted, so a newly loaded pack is announced. */}
|
||||
<div role="status" aria-live="polite">
|
||||
{stored ? <PackAgeNote stored={stored} now={dataUpdatedAt} /> : null}
|
||||
{isOffline ? <p className="text-sm text-muted-foreground">Laden braucht Netz.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,11 +34,11 @@ function setOnLine(value: boolean) {
|
||||
}
|
||||
|
||||
/** The band as the layout places it: above the page content wrapper. */
|
||||
function BandOnPage() {
|
||||
function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) {
|
||||
const pageContentRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
|
||||
<OfflineBand userId="user-1" pageContentRef={pageContentRef} syncEnabled={syncEnabled} />
|
||||
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
|
||||
Seiteninhalt
|
||||
</div>
|
||||
@@ -46,11 +46,11 @@ function BandOnPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderBand() {
|
||||
function renderBand(props: { syncEnabled?: boolean } = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<BandOnPage />
|
||||
<BandOnPage {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -260,4 +260,39 @@ describe("OfflineBand", () => {
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
});
|
||||
|
||||
describe("in an offline session (no access token)", () => {
|
||||
it("neither opens the sync gate nor sends, even when the connection is back", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand({ syncEnabled: false });
|
||||
await screen.findByText("Offline – 2 Einträge warten");
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /Jetzt senden/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the gate and sends as soon as the session is online again", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = (syncEnabled: boolean) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<BandOnPage syncEnabled={syncEnabled} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(view(false));
|
||||
await screen.findByText("Offline – 1 Eintrag wartet");
|
||||
|
||||
rerender(view(true));
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
expect(resumeSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@ import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
const COUNT_INTERVAL_MS = 15_000;
|
||||
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
|
||||
// Both auto-sync triggers can fire together; they share one sync run
|
||||
// (syncPendingEntries), and one toast id keeps it to one message.
|
||||
// (syncPendingEntries), even one already running - they change nothing that
|
||||
// is to be sent - and one toast id keeps it to one message.
|
||||
const SYNC_TOAST_ID = "offline-sync";
|
||||
// Wraps on narrow phones instead of overflowing; never below 44 px.
|
||||
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
|
||||
@@ -26,6 +27,12 @@ interface OfflineBandProps {
|
||||
userId: string;
|
||||
/** Focus target when the queue sheet closes and the band shows nothing. */
|
||||
pageContentRef: RefObject<HTMLElement | null>;
|
||||
/**
|
||||
* False in an offline session (opened from the cached identity, no access
|
||||
* token): the band then shows what waits but neither opens the sync gate
|
||||
* nor sends - the server would only answer 401.
|
||||
*/
|
||||
syncEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +40,7 @@ interface OfflineBandProps {
|
||||
* device, how much still waits to be sent and what the server rejected.
|
||||
* Sends by itself as soon as the connection is back.
|
||||
*/
|
||||
export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const connectivity = useConnectivity();
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
@@ -68,24 +75,31 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
|
||||
// The band only exists for a signed-in user, and the auth context sets that
|
||||
// user's access token before the user: the moment to reopen sync after a
|
||||
// logout's cancelSync(). Declared first, so it runs before the sync below.
|
||||
// logout's cancelSync(). Not without a token (offline session): the gate
|
||||
// stays closed until the session is online. Declared first, so it runs
|
||||
// before the sync below.
|
||||
useEffect(() => {
|
||||
resumeSync();
|
||||
}, [userId]);
|
||||
if (syncEnabled) {
|
||||
resumeSync();
|
||||
}
|
||||
}, [userId, syncEnabled]);
|
||||
|
||||
// Mount, and every switch back to online (a request reached the server again).
|
||||
useEffect(() => {
|
||||
if (connectivity === "online") {
|
||||
if (syncEnabled && connectivity === "online") {
|
||||
startSync();
|
||||
}
|
||||
}, [connectivity, startSync, userId]);
|
||||
}, [connectivity, startSync, userId, syncEnabled]);
|
||||
|
||||
// The browser's own `online` event: try even while the last request failed.
|
||||
useEffect(() => {
|
||||
if (!syncEnabled) {
|
||||
return;
|
||||
}
|
||||
const handleOnline = () => startSync();
|
||||
window.addEventListener("online", handleOnline);
|
||||
return () => window.removeEventListener("online", handleOnline);
|
||||
}, [startSync]);
|
||||
}, [startSync, syncEnabled]);
|
||||
|
||||
// The sheet stays open while the user still has entries in it (waiting
|
||||
// ones too, after the last rejected one was discarded), and closes once
|
||||
@@ -95,7 +109,8 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
setSheetOpen(false);
|
||||
}
|
||||
|
||||
const isOffline = connectivity === "offline";
|
||||
// An offline session cannot send, whatever the network says.
|
||||
const isOffline = connectivity === "offline" || !syncEnabled;
|
||||
const hasContent = isOffline || queue.own > 0 || hasRejected;
|
||||
|
||||
// Back to the trigger while it exists; otherwise to the band's message, or
|
||||
|
||||
@@ -2,20 +2,27 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { discardQueueItem, listQueue, type QueueItem, type StoredPhoto } from "@/lib/offline/db";
|
||||
import {
|
||||
discardQueueItem,
|
||||
listQueue,
|
||||
retryBulkWithout,
|
||||
type QueueItem,
|
||||
type StoredPhoto,
|
||||
} from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
|
||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
|
||||
vi.mock("@/lib/offline/db", () => ({
|
||||
listQueue: vi.fn(),
|
||||
discardQueueItem: vi.fn(),
|
||||
retryBulkWithout: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/photos/store")>()),
|
||||
listPendingPhotos: vi.fn(),
|
||||
removePhoto: vi.fn(),
|
||||
discardReadyPhoto: vi.fn(),
|
||||
resetPhotoError: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -85,14 +92,67 @@ describe("PendingQueueSheet", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
|
||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(removePhoto).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
const REJECTED_BULK: QueueItem = {
|
||||
store: "pending-bulk",
|
||||
id: 9,
|
||||
label: "Behandlung für mehrere Völker",
|
||||
queuedAt: "2026-10-14T08:00:00.000Z",
|
||||
lastError: {
|
||||
status: 400,
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
at: "2026-10-14T09:00:00.000Z",
|
||||
},
|
||||
resendWithout: { colonyIds: ["c-3"], codes: ["V-3"] },
|
||||
};
|
||||
|
||||
it("„Ohne V-3 erneut senden“ resends the remaining ids with the same key", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED_BULK]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
await userEvent.click(await within(sheet).findByRole("button", { name: /^Ohne V-3 erneut senden/ }));
|
||||
|
||||
// The entry keeps its key; only the refused colony leaves, then a run sends it.
|
||||
await waitFor(() => expect(retryBulkWithout).toHaveBeenCalledWith(9, ["c-3"]));
|
||||
await waitFor(() =>
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
|
||||
);
|
||||
expect(vi.mocked(retryBulkWithout).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("names the button generally when a refused colony is not known by code", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{ ...REJECTED_BULK, resendWithout: { colonyIds: ["c-3", "c-4"], codes: ["V-3"] } },
|
||||
]);
|
||||
renderSheet();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /^Ohne die abgelehnten Völker erneut senden/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no resend for a waiting bulk entry or a single rejected entry", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{ ...REJECTED_BULK, lastError: undefined, resendWithout: undefined },
|
||||
REJECTED,
|
||||
]);
|
||||
renderSheet();
|
||||
await screen.findAllByText("Behandlung für mehrere Völker");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /erneut senden/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows „+ 2 Fotos“ at a queued inspection and discards them with it", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([{ ...REJECTED, photoCount: 2 }]);
|
||||
renderSheet();
|
||||
@@ -126,7 +186,10 @@ describe("PendingQueueSheet", () => {
|
||||
|
||||
await userEvent.click(retry[0]);
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
// It changed what is to be sent: a sync already running gets one more run.
|
||||
await waitFor(() =>
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
|
||||
);
|
||||
expect(resetPhotoError).toHaveBeenCalledWith("photo-1");
|
||||
expect(vi.mocked(resetPhotoError).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
@@ -144,7 +207,7 @@ describe("PendingQueueSheet", () => {
|
||||
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await waitFor(() => expect(removePhoto).toHaveBeenCalledWith("photo-1"));
|
||||
await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1"));
|
||||
expect(within(sheet).queryByText("Von dir wartet nichts mehr.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ import { NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { discardQueueItem, listQueue, type QueueError, type QueueItem } from "@/lib/offline/db";
|
||||
import {
|
||||
discardQueueItem,
|
||||
listQueue,
|
||||
retryBulkWithout,
|
||||
type QueueError,
|
||||
type QueueItem,
|
||||
} from "@/lib/offline/db";
|
||||
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
|
||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||
|
||||
interface PendingQueueSheetProps {
|
||||
userId: string;
|
||||
@@ -80,6 +86,33 @@ function QueueStatus({ lastError }: { lastError?: QueueError }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
|
||||
function ResendWithoutButton({
|
||||
resend,
|
||||
disabled,
|
||||
onResend,
|
||||
}: {
|
||||
resend: NonNullable<QueueItem["resendWithout"]>;
|
||||
disabled: boolean;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const isNamed = resend.codes.length === resend.colonyIds.length;
|
||||
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
disabled={disabled}
|
||||
onClick={onResend}
|
||||
>
|
||||
<RotateCw aria-hidden />
|
||||
Ohne {without} erneut senden
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What this device still has to send for the signed-in user: each entry with
|
||||
* its state, a rejected one with the server's reason, and a way to discard it.
|
||||
@@ -120,7 +153,7 @@ export function PendingQueueSheet({
|
||||
});
|
||||
|
||||
const discardPhoto = useMutation({
|
||||
mutationFn: (photoId: string) => removePhoto(photoId),
|
||||
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
@@ -133,7 +166,8 @@ export function PendingQueueSheet({
|
||||
const retryPhoto = useMutation({
|
||||
mutationFn: async (photoId: string) => {
|
||||
await resetPhotoError(photoId);
|
||||
return syncPendingEntries(userId);
|
||||
// A sync already running may have passed this photo: it runs once more.
|
||||
return syncPendingEntries(userId, { afterChange: true });
|
||||
},
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
@@ -148,6 +182,26 @@ export function PendingQueueSheet({
|
||||
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
|
||||
// key, rejection cleared), then a normal run sends the rest.
|
||||
const resendBulk = useMutation({
|
||||
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
|
||||
await retryBulkWithout(id, colonyIds);
|
||||
// A sync already running may have passed this entry: it runs once more.
|
||||
return syncPendingEntries(userId, { afterChange: true });
|
||||
},
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
if (result.synced > 0) {
|
||||
toast.success(entriesSynced(result.synced));
|
||||
void queryClient.invalidateQueries();
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
},
|
||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
const names = entryNames(items);
|
||||
const photoNames = entryNames(
|
||||
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
|
||||
@@ -197,6 +251,18 @@ export function PendingQueueSheet({
|
||||
/>
|
||||
</div>
|
||||
<QueueStatus lastError={item.lastError} />
|
||||
{item.resendWithout && typeof item.id === "number" ? (
|
||||
<ResendWithoutButton
|
||||
resend={item.resendWithout}
|
||||
disabled={resendBulk.isPending}
|
||||
onResend={() =>
|
||||
resendBulk.mutate({
|
||||
id: item.id as number,
|
||||
colonyIds: item.resendWithout?.colonyIds ?? [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
{photos.map((photo, index) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { addDraftPhoto, listSessionPhotos } from "@/lib/photos/store";
|
||||
import { addDraftPhoto, attachSessionToQueuedInspection, listSessionPhotos } from "@/lib/photos/store";
|
||||
import { PhotoMetadataError, preparePhoto } from "@/lib/photos/reencode";
|
||||
import { PhotoCaptureField } from "./photo-capture-field";
|
||||
|
||||
@@ -72,6 +72,16 @@ describe("PhotoCaptureField", () => {
|
||||
expect(screen.getByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps its announcement region present while it is empty", () => {
|
||||
renderField();
|
||||
|
||||
// Hidden while empty, it would swallow the first message (jsdom applies no CSS).
|
||||
const live = screen.getByRole("status");
|
||||
expect(live).toHaveAttribute("aria-live", "polite");
|
||||
expect(live).toBeEmptyDOMElement();
|
||||
expect([...live.classList].some((name) => /(^|:)hidden$/.test(name))).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden without documents:upload", () => {
|
||||
permissions = new Set();
|
||||
const { container } = renderField();
|
||||
@@ -129,6 +139,19 @@ describe("PhotoCaptureField", () => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers „Entfernen“ only for photos that are not saved yet", async () => {
|
||||
await storePhotos(1);
|
||||
await attachSessionToQueuedInspection(sessionId, "inspection-key");
|
||||
await storePhotos(1);
|
||||
renderField();
|
||||
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
|
||||
|
||||
// Foto 1 already belongs to an inspection in the queue; only Foto 2 is the form's own.
|
||||
expect(screen.queryByRole("button", { name: "Entfernen: Foto 1" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Entfernen: Foto 2" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Gespeichert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps nothing when the location data could not be removed, and says so", async () => {
|
||||
vi.mocked(preparePhoto).mockRejectedValue(new PhotoMetadataError());
|
||||
const { container } = renderField();
|
||||
|
||||
@@ -177,17 +177,24 @@ function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProp
|
||||
alt={name}
|
||||
className="aspect-square w-full rounded-lg border border-border/60 object-cover"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
triggerLabel="Entfernen"
|
||||
triggerAriaLabel={`Entfernen: ${name}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${name} entfernen?`}
|
||||
description="Das Foto wird von diesem Gerät gelöscht."
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={() => remove.mutate(photo.id)}
|
||||
isPending={remove.isPending}
|
||||
/>
|
||||
{/* A photo of a saved or queued inspection is never removed from a form. */}
|
||||
{photo.state === "draft" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Entfernen"
|
||||
triggerAriaLabel={`Entfernen: ${name}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${name} entfernen?`}
|
||||
description="Das Foto wird von diesem Gerät gelöscht."
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={() => remove.mutate(photo.id)}
|
||||
isPending={remove.isPending}
|
||||
/>
|
||||
) : (
|
||||
<p className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">
|
||||
Gespeichert
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -214,11 +221,12 @@ function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProp
|
||||
aria-hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
{/* Present while empty (only visually hidden), or its first message is not announced. */}
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"text-sm empty:hidden",
|
||||
"text-sm empty:sr-only",
|
||||
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { Field } from "@/components/form/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { mitesPerDay } from "@/lib/varroa/rate";
|
||||
|
||||
interface BulkMiteCountsProps {
|
||||
/** The chosen colonies, in the order they are listed. */
|
||||
colonies: readonly { id: string; code: string }[];
|
||||
/** The typed count of each colony, by id. */
|
||||
counts: Readonly<Record<string, string>>;
|
||||
onCountChange: (colonyId: string, count: string) => void;
|
||||
/** Shared by all colonies; with a natural mite fall it gives each one its rate. */
|
||||
durationMinutes: string;
|
||||
showsRate: boolean;
|
||||
threshold: number | null;
|
||||
errors: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mite count per colony - there is no shared count - and, for a natural
|
||||
* mite fall, the rate with its traffic light right under each count.
|
||||
*/
|
||||
export function BulkMiteCounts({
|
||||
colonies,
|
||||
counts,
|
||||
onCountChange,
|
||||
durationMinutes,
|
||||
showsRate,
|
||||
threshold,
|
||||
errors,
|
||||
}: BulkMiteCountsProps) {
|
||||
const missing = colonies.filter(({ id }) => (counts[id] ?? "").trim() === "");
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{missing.length > 0
|
||||
? `Noch ohne Milbenzahl: ${missing.map(({ code }) => code).join(", ")}`
|
||||
: colonies.length > 0
|
||||
? "Jedes Volk hat eine Milbenzahl."
|
||||
: ""}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-4">
|
||||
{colonies.map(({ id, code }) => {
|
||||
const count = counts[id] ?? "";
|
||||
return (
|
||||
<li key={id}>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={code}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Field label={`Milbenzahl ${code}`} required error={errors[id]}>
|
||||
{({ id: inputId, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={inputId}
|
||||
data-field={`miteCount-${id}`}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={count}
|
||||
onChange={(event) =>
|
||||
onCountChange(id, event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{showsRate ? (
|
||||
<VarroaRateLight
|
||||
rate={mitesPerDay(
|
||||
emptyToNull(count, Number),
|
||||
emptyToNull(durationMinutes, Number),
|
||||
)}
|
||||
threshold={threshold}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user