feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)
- lib/hive: frontend port of the hive domain (ops, validator, facts, layout, follow-ups, describe texts, reason texts) running the backend fixtures, API client - hive card on the colony page (same height as Stammdaten): SVG view, accessible outline tree, frame/part sheets with optimistic save, conflict sheet, history with past states, init from template or another colony, inspection draft mode - follow-up toasts prefilling feeding and treatment forms (treatment end only with an open treatment) - settings page "Beutensysteme" (hive-systems:manage) - NumberStepper decimal steps, reactive motion level hook, holo tokens - spec: owner design decision 2026-10-16 (glass hologram, focus mode) The SVG view is the fallback; the 3D scene follows in L3. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0091b3d28b
commit
c608f5d405
87 files changed
+9493
-43
No files matched your search
@@ -0,0 +1,101 @@
|
||||
import { toast } from "sonner";
|
||||
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
import { hiveFollowUps } from "@/lib/hive/follow-ups";
|
||||
import type { FollowUp } from "@/lib/hive/follow-ups";
|
||||
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
|
||||
// Spec section 8: the toast only suggests; the form it links to is prefilled
|
||||
// and the beekeeper books (or not) on their own responsibility.
|
||||
|
||||
const TOAST_DURATION_MS = 12_000;
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
export interface FollowUpContext {
|
||||
colonyId: string;
|
||||
/** Berlin day of the change; becomes the form's date. */
|
||||
occurredOn: string;
|
||||
navigate: (href: string) => void;
|
||||
/** Whether the colony has a treatment without end date; „Behandlung beenden?“ needs one. */
|
||||
hasOpenTreatment: boolean;
|
||||
}
|
||||
|
||||
/** Link with the query order the spec fixes: colonyId, date, amount, unit, feedType. */
|
||||
function link(path: string, entries: [string, string][]): string {
|
||||
return `${path}?${new URLSearchParams(entries).toString()}`;
|
||||
}
|
||||
|
||||
/** The feeder field's own name („Flüssigfutter“), else the feed type's. */
|
||||
function feedName(followUp: Extract<FollowUp, { kind: "feeding" }>, catalog: HiveCatalog): string {
|
||||
for (const type of catalog.partTypes.values()) {
|
||||
if (type.kind !== "FEEDER") continue;
|
||||
for (const field of type.stateSchema) {
|
||||
if (field.kind === "level" && field.feedType === followUp.feedType && field.unit === followUp.unit) {
|
||||
return field.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
return FEED_TYPE_LABELS[followUp.feedType];
|
||||
}
|
||||
|
||||
function addsEvaporator(ops: HiveOp[], catalog: HiveCatalog): boolean {
|
||||
return ops.some(
|
||||
(op) =>
|
||||
(op.op === "addInsert" || op.op === "addPart") &&
|
||||
catalog.partTypes.get(op.part.partTypeId)?.kind === "EVAPORATOR",
|
||||
);
|
||||
}
|
||||
|
||||
function showOne(followUp: FollowUp, ops: HiveOp[], catalog: HiveCatalog, context: FollowUpContext): void {
|
||||
const { colonyId, occurredOn, navigate } = context;
|
||||
if (followUp.kind === "feeding") {
|
||||
const href = link("/feedings/new", [
|
||||
["colonyId", colonyId],
|
||||
["date", occurredOn],
|
||||
["amount", String(followUp.amount)],
|
||||
["unit", followUp.unit],
|
||||
["feedType", followUp.feedType],
|
||||
]);
|
||||
const amount = `${numberFormat.format(followUp.amount)} ${followUp.unit}`;
|
||||
toast(`${amount} ${feedName(followUp, catalog)} nachgefüllt. Als Fütterung eintragen?`, {
|
||||
duration: TOAST_DURATION_MS,
|
||||
action: { label: "Fütterung eintragen", onClick: () => navigate(href) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (followUp.kind === "treatment") {
|
||||
const href = link("/treatments/new", [
|
||||
["colonyId", colonyId],
|
||||
["date", occurredOn],
|
||||
["amount", String(followUp.amountMl)],
|
||||
["unit", "ml"],
|
||||
]);
|
||||
const verb = addsEvaporator(ops, catalog) ? "eingesetzt" : "nachgefüllt";
|
||||
toast(`Verdunster mit ${numberFormat.format(followUp.amountMl)} ml ${verb}. Als Behandlung eintragen?`, {
|
||||
duration: TOAST_DURATION_MS,
|
||||
action: { label: "Behandlung eintragen", onClick: () => navigate(href) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!context.hasOpenTreatment) return;
|
||||
toast("Behandlung beenden?", {
|
||||
duration: TOAST_DURATION_MS,
|
||||
action: {
|
||||
label: "Behandlungen öffnen",
|
||||
onClick: () => navigate(`/colonies/${encodeURIComponent(colonyId)}#behandlungen`),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** One toast per suggestion of `hiveFollowUps`; nothing is booked here. */
|
||||
export function showHiveFollowUps(
|
||||
ops: HiveOp[],
|
||||
before: HiveState,
|
||||
after: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
context: FollowUpContext,
|
||||
): void {
|
||||
for (const followUp of hiveFollowUps(ops, before, after, catalog)) {
|
||||
showOne(followUp, ops, catalog, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRef, useState } from "react";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { FrameSheet } from "./frame-sheet";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b1", "box", {
|
||||
frames: frames(10).map((frame) =>
|
||||
frame.slot === 4 ? { ...frame, type: "BROOD" as const, name: "Königin" } : frame,
|
||||
),
|
||||
}),
|
||||
part("b2", "box", { frames: frames(10) }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
|
||||
interface HarnessProps {
|
||||
submit: (ops: HiveOp[]) => Promise<unknown>;
|
||||
boxId?: string;
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
function Harness({ submit, boxId = "b1", slot = 4 }: HarnessProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
const [store] = useState(createHiveViewStore);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => setOpen(true)}
|
||||
/>
|
||||
<FrameSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
boxId={boxId}
|
||||
slot={slot}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole("button", { name: "Speichern" });
|
||||
|
||||
describe("FrameSheet", () => {
|
||||
it("is titled with the slot and names the part", () => {
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: "Rähmchen 4" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Zarge 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the ten frame types by their German names with the current one chosen", () => {
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(10);
|
||||
expect(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.BROOD })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.NONE })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Speichern disabled while nothing changed", () => {
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves one setFrame with the chosen type and the trimmed name", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
|
||||
const name = screen.getByLabelText("Name");
|
||||
await user.clear(name);
|
||||
await user.type(name, " Vorrat ");
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "setFrame", boxId: "b1", slot: 4, type: "HONEY", name: "Vorrat" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends name null when the name is emptied", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.clear(screen.getByLabelText("Name"));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "setFrame", boxId: "b1", slot: 4, type: "BROOD", name: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits the name to 40 characters and shows a counter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={vi.fn()} slot={1} />);
|
||||
const name = screen.getByLabelText("Name");
|
||||
expect(name).toHaveAttribute("maxlength", "40");
|
||||
await user.type(name, "x".repeat(45));
|
||||
expect(name).toHaveValue("x".repeat(40));
|
||||
expect(screen.getByText("40 / 40")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Speichern while the request is pending", async () => {
|
||||
let finish: () => void = () => undefined;
|
||||
const submit = vi.fn().mockReturnValue(new Promise<void>((resolve) => (finish = resolve)));
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
|
||||
await user.click(save());
|
||||
expect(save()).toBeDisabled();
|
||||
finish();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("shows an error inline and stays open when saving fails", async () => {
|
||||
const submit = vi.fn().mockRejectedValue(new ApiError(500, "kaputt", null));
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
|
||||
await user.click(save());
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on a conflict so the conflict sheet can take over", async () => {
|
||||
const submit = vi.fn().mockRejectedValue(
|
||||
new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "x", current: {} }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("returns focus to the edited frame in the outline, not to the body", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /R4 ·/ })).toHaveFocus());
|
||||
expect(document.body).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("returns focus to the frame also when closed with Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /R4 ·/ })).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { FRAME_TYPES, MAX_TEXT_LENGTH } from "@/lib/hive/types";
|
||||
import type { FrameType, HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import { useSheetSubmit } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const FRAME_OPTIONS = choiceOptions(FRAME_TYPE_LABELS, FRAME_TYPES);
|
||||
|
||||
export interface FrameSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
boxId: string;
|
||||
slot: number;
|
||||
/** Sends the ops (source CARD); rejects on failure. */
|
||||
submit: SubmitOps;
|
||||
/** On close, focus returns to this frame's item in the outline. */
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
}
|
||||
|
||||
interface FrameFormProps {
|
||||
initialType: FrameType;
|
||||
initialName: string;
|
||||
boxId: string;
|
||||
slot: number;
|
||||
submit: SubmitOps;
|
||||
/** Saved, or the hive changed meanwhile (the conflict sheet takes over). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: FrameFormProps) {
|
||||
const [type, setType] = useState<FrameType>(initialType);
|
||||
const [name, setName] = useState(initialName);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
const trimmed = name.trim();
|
||||
const changed = type !== initialType || trimmed !== initialName.trim();
|
||||
|
||||
async function save() {
|
||||
const outcome = await send([{ op: "setFrame", boxId, slot, type, name: trimmed === "" ? null : trimmed }]);
|
||||
if (outcome !== "failed") onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-4 pb-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (changed && !pending) void save();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`frame-${boxId}-${slot}-type`} className="text-sm font-medium">
|
||||
Art
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={FRAME_OPTIONS}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
labelledBy={`frame-${boxId}-${slot}-type`}
|
||||
className="grid-cols-2 sm:grid-cols-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={`frame-${boxId}-${slot}-name`} className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id={`frame-${boxId}-${slot}-name`}
|
||||
value={name}
|
||||
maxLength={MAX_TEXT_LENGTH}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
aria-describedby={`frame-${boxId}-${slot}-count`}
|
||||
className="h-11"
|
||||
/>
|
||||
<p id={`frame-${boxId}-${slot}-count`} className="text-xs text-muted-foreground">
|
||||
{name.length} / {MAX_TEXT_LENGTH}
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button type="submit" size="touch" disabled={!changed || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit one frame of a box: its type and an optional name (spec 9.4). One `setFrame` per save. */
|
||||
export function FrameSheet({ open, onOpenChange, state, catalog, boxId, slot, submit, outlineRef }: FrameSheetProps) {
|
||||
const box = state.stack.find((candidate) => candidate.id === boxId);
|
||||
const frame = box?.frames?.find((candidate) => candidate.slot === slot);
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={`Rähmchen ${slot}`}
|
||||
description={partDisplayName(state, catalog, boxId)}
|
||||
finalFocus={() => {
|
||||
outlineRef.current?.focusFrame(boxId, slot);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{frame ? (
|
||||
<FrameForm
|
||||
// A different frame starts a fresh draft.
|
||||
key={`${boxId}#${slot}`}
|
||||
initialType={frame.type}
|
||||
initialName={frame.name ?? ""}
|
||||
boxId={boxId}
|
||||
slot={slot}
|
||||
submit={submit}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
|
||||
Dieses Rähmchen gibt es nicht mehr.
|
||||
</p>
|
||||
)}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
fetchHive,
|
||||
fetchHiveAt,
|
||||
fetchHiveHistory,
|
||||
fetchHiveSystems,
|
||||
fetchHiveTemplate,
|
||||
postHiveChange,
|
||||
} from "@/lib/hive/api";
|
||||
import { templateState } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import { HiveCard } from "./hive-card";
|
||||
import { colonyFixture, historyItem, sampleState, setupView, systemDto } from "./test-fixtures";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("sonner", () => ({ toast: vi.fn() }));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHive: vi.fn(),
|
||||
fetchHiveSystems: vi.fn(),
|
||||
fetchHiveTemplate: vi.fn(),
|
||||
fetchHiveHistory: vi.fn(),
|
||||
fetchHiveAt: vi.fn(),
|
||||
postHiveChange: vi.fn(),
|
||||
createHive: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
|
||||
}));
|
||||
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
|
||||
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/offline/connectivity")>()),
|
||||
useConnectivity: () => connectivity.value,
|
||||
}));
|
||||
|
||||
function renderCard(props: Partial<React.ComponentProps<typeof HiveCard>> = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveCard colonyId="colony-1" colony={colonyFixture()} {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const FRAME_EDIT_OPS: HiveOp[] = [{ op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }];
|
||||
|
||||
/** Opens Zarge 1 in the outline, picks frame 5 and chooses „Brutwabe“. */
|
||||
async function editFrameFive() {
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Rähmchen 5" });
|
||||
await userEvent.click(within(dialog).getByRole("radio", { name: "Brutwabe" }));
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Speichern" }));
|
||||
}
|
||||
|
||||
describe("HiveCard", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["colonies:read", "colonies:update"];
|
||||
connectivity.value = "online";
|
||||
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
|
||||
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
|
||||
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
|
||||
vi.mocked(fetchHiveHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
vi.mocked(fetchHiveAt).mockReset();
|
||||
vi.mocked(postHiveChange).mockReset();
|
||||
});
|
||||
|
||||
it("shows the model, the facts line and where the state comes from", async () => {
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Stand: 12.10. aus Durchsicht")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is the right column of the first row and as tall as the card beside it", async () => {
|
||||
const { container } = renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
const root = container.querySelector("[data-slot=card]")!;
|
||||
expect(root).toHaveClass("lg:col-span-2", "min-h-80", "lg:h-auto");
|
||||
expect(root.className).toContain("h-[min(55svh,520px)]");
|
||||
});
|
||||
|
||||
it("offers the hive setup instead of a model when the colony has none", async () => {
|
||||
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows readers only „Noch keine Beute angelegt“", async () => {
|
||||
permissions = ["colonies:read"];
|
||||
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains a failed load, keeps the card and retries on request", async () => {
|
||||
vi.mocked(fetchHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {}));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht geladen werden.");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
|
||||
expect(fetchHive).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("Umbauen", () => {
|
||||
it("links to the editor route", async () => {
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Umbauen" })).toHaveAttribute("href", "/colonies/colony-1/hive");
|
||||
});
|
||||
|
||||
it("is hidden without colonies:update", async () => {
|
||||
permissions = ["colonies:read"];
|
||||
renderCard();
|
||||
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden for a colony that no longer lives", async () => {
|
||||
renderCard({ colony: colonyFixture({ status: "DISSOLVED" }) });
|
||||
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is disabled offline and says why", async () => {
|
||||
connectivity.value = "offline";
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Umbauen" })).toBeDisabled();
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
|
||||
renderCard();
|
||||
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
|
||||
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
|
||||
|
||||
const wrapper = document.getElementById(toggle.getAttribute("aria-controls")!)!;
|
||||
expect(wrapper).toHaveClass("sr-only");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(wrapper).not.toHaveClass("sr-only");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("keeps the outline from growing the card: it scrolls in an absolutely placed child", async () => {
|
||||
renderCard();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
|
||||
|
||||
const scroll = screen.getByTestId("hive-outline-scroll");
|
||||
expect(scroll).toHaveClass("sm:absolute", "sm:inset-0", "sm:overflow-y-auto");
|
||||
expect(scroll.parentElement).toHaveClass("sm:relative", "sm:w-72");
|
||||
});
|
||||
|
||||
it("points „Aufbau als Liste“ at the outline", async () => {
|
||||
renderCard();
|
||||
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
|
||||
|
||||
const target = document.getElementById(toggle.getAttribute("aria-controls")!);
|
||||
expect(target).toContainElement(screen.getByRole("tree"));
|
||||
});
|
||||
|
||||
describe("editing", () => {
|
||||
it("saves a frame change from the outline as a CARD change", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(setupView());
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await editFrameFive();
|
||||
|
||||
expect(postHiveChange).toHaveBeenCalledWith(
|
||||
"colony-1",
|
||||
{ occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), source: "CARD", ops: FRAME_EDIT_OPS },
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not open a frame sheet for a reader", async () => {
|
||||
permissions = ["colonies:read"];
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("in inspection mode keeps a draft and reports the ops instead of saving", async () => {
|
||||
const onOpsChange = vi.fn();
|
||||
renderCard({ mode: "inspection", onOpsChange });
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await editFrameFive();
|
||||
|
||||
expect(onOpsChange).toHaveBeenCalledWith(FRAME_EDIT_OPS);
|
||||
expect(postHiveChange).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the conflict sheet when the hive changed meanwhile", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(
|
||||
new ApiError(409, "Konflikt", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason: "x",
|
||||
current: { version: 9, state: sampleState() },
|
||||
}),
|
||||
);
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await editFrameFive();
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verlauf", () => {
|
||||
it("shows the state of a past day read-only and returns to today", async () => {
|
||||
const past = { ...sampleState(), stack: sampleState().stack.slice(0, 2) };
|
||||
vi.mocked(fetchHiveAt).mockResolvedValue({
|
||||
version: 2,
|
||||
state: past,
|
||||
facts: {} as never,
|
||||
occurredOn: "2026-08-12",
|
||||
});
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({
|
||||
items: [historyItem({ occurredOn: "2026-08-12", createdAt: "2026-08-12T10:00:00.000Z" })],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verlauf" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Verlauf" });
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: /12\.08\./ }));
|
||||
|
||||
expect(await screen.findByText(/Stand vom 12\.08\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("1 Zarge · 3 Brutwaben · Bausperre drin")).toBeInTheDocument();
|
||||
// Read-only: a frame cannot be edited while a past state is shown.
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge/ }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
|
||||
|
||||
expect(screen.queryByText(/Stand vom/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveFocus();
|
||||
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History, ListTree } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { FrameSheet } from "./frame-sheet";
|
||||
import { HiveConflictSheet } from "./hive-conflict-sheet";
|
||||
import { HiveFactsLine } from "./hive-facts-line";
|
||||
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
|
||||
import { HiveInit } from "./hive-init";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import { HiveVisualSlot } from "./hive-visual-slot";
|
||||
import { PartSheet } from "./part-sheet";
|
||||
import { useHiveMutation } from "./use-hive-mutation";
|
||||
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
|
||||
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
import { HiveDraftError } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const LOAD_FAILED_TEXT = "Beute konnte nicht geladen werden.";
|
||||
const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz";
|
||||
|
||||
export interface HiveCardProps {
|
||||
colonyId: string;
|
||||
colony: Colony;
|
||||
/** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */
|
||||
mode?: "view" | "inspection";
|
||||
onOpsChange?: (ops: HiveOp[]) => void;
|
||||
}
|
||||
|
||||
/** Colony page card: model, facts line, outline, history (spec 9). Fixed height below lg, as tall as „Stammdaten“ above. */
|
||||
export function HiveCard(props: HiveCardProps) {
|
||||
const [store] = useState(createHiveViewStore);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<Card className="h-[min(55svh,520px)] min-h-80 lg:col-span-2 lg:h-auto lg:min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Beute</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<HiveCardContent {...props} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
|
||||
const systems = useHiveSystems();
|
||||
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
|
||||
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
|
||||
|
||||
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
|
||||
return <HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />;
|
||||
}
|
||||
if (hive.isError || systems.isError) {
|
||||
return (
|
||||
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => {
|
||||
if (hive.isError) void hive.refetch();
|
||||
if (systems.isError) void systems.refetch();
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hive.data || !catalog) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Beute wird geladen"
|
||||
className="min-h-40 flex-1 rounded-lg bg-muted motion-safe:animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HiveCardBody
|
||||
colonyId={colonyId}
|
||||
setup={hive.data}
|
||||
catalog={catalog}
|
||||
canEdit={canEdit}
|
||||
mode={mode}
|
||||
onOpsChange={onOpsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameTarget {
|
||||
boxId: string;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
interface PartTarget {
|
||||
id: string;
|
||||
evaporator: boolean;
|
||||
}
|
||||
|
||||
interface PastState {
|
||||
state: HiveState;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
function findPartKind(state: HiveState, catalog: HiveCatalog, id: string) {
|
||||
for (const part of state.stack) {
|
||||
const hit = part.id === id ? part : part.inserts?.find((insert) => insert.id === id);
|
||||
if (hit) return catalog.partTypes.get(hit.partTypeId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface HiveCardBodyProps {
|
||||
colonyId: string;
|
||||
setup: HiveSetupView;
|
||||
catalog: HiveCatalog;
|
||||
canEdit: boolean;
|
||||
mode: "view" | "inspection";
|
||||
onOpsChange?: (ops: HiveOp[]) => void;
|
||||
}
|
||||
|
||||
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }: HiveCardBodyProps) {
|
||||
const view = useHiveView();
|
||||
const connectivity = useConnectivity();
|
||||
const mutation = useHiveMutation(colonyId, catalog);
|
||||
const history = useHiveHistory(colonyId);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
const historyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const outlineId = useId();
|
||||
|
||||
const [draftOps, setDraftOps] = useState<HiveOp[]>([]);
|
||||
const [past, setPast] = useState<PastState | null>(null);
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [frameTarget, setFrameTarget] = useState<FrameTarget | null>(null);
|
||||
const [frameOpen, setFrameOpen] = useState(false);
|
||||
const [partTarget, setPartTarget] = useState<PartTarget | null>(null);
|
||||
const [partOpen, setPartOpen] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
const isInspection = mode === "inspection";
|
||||
// The state edits apply to: the loaded one, plus the local draft in inspection mode.
|
||||
const workingState = useMemo(() => {
|
||||
if (!isInspection || draftOps.length === 0) return setup.state;
|
||||
const applied = applyHiveOps(setup.state, draftOps, catalog);
|
||||
return applied.ok ? applied.state : setup.state;
|
||||
}, [isInspection, draftOps, setup.state, catalog]);
|
||||
const shownState = past?.state ?? workingState;
|
||||
const editable = canEdit && past === null;
|
||||
|
||||
const facts = useMemo(() => deriveHiveFacts(shownState, catalog), [shownState, catalog]);
|
||||
const layout = useMemo(() => hiveLayout(shownState, catalog), [shownState, catalog]);
|
||||
const lastChange = history.data?.pages[0]?.items[0];
|
||||
|
||||
const submit: SubmitOps = async (ops) => {
|
||||
const before = workingState;
|
||||
if (isInspection) {
|
||||
const next = [...draftOps, ...ops];
|
||||
const applied = applyHiveOps(setup.state, next, catalog);
|
||||
if (!applied.ok) throw new HiveDraftError(hiveReasonText(applied.reason));
|
||||
setDraftOps(next);
|
||||
onOpsChange?.(next);
|
||||
} else {
|
||||
await mutation.submit(ops, "CARD");
|
||||
}
|
||||
setAnnouncement(`${describeHiveOps(ops, before, catalog)}.`);
|
||||
};
|
||||
|
||||
const partName = (id: string) => partDisplayName(shownState, catalog, id);
|
||||
|
||||
function editFrame(boxId: string, slot: number) {
|
||||
if (!editable) return;
|
||||
setFrameTarget({ boxId, slot });
|
||||
setFrameOpen(true);
|
||||
}
|
||||
|
||||
function editPart(id: string) {
|
||||
if (!editable) return;
|
||||
// The outline's „Verdunster einsetzen“ action reports its box.
|
||||
const evaporator = findPartKind(workingState, catalog, id)?.kind === "BOX";
|
||||
setPartTarget({ id, evaporator });
|
||||
setPartOpen(true);
|
||||
}
|
||||
|
||||
function selectPart(id: string) {
|
||||
const type = findPartKind(shownState, catalog, id);
|
||||
if (type?.kind === "BOX") {
|
||||
view.openBox(id);
|
||||
return;
|
||||
}
|
||||
if (type && (type.stateSchema.length > 0 || type.kind === "FLOOR")) editPart(id);
|
||||
}
|
||||
|
||||
const showRebuild = canEdit && !isInspection;
|
||||
const offline = connectivity === "offline";
|
||||
|
||||
return (
|
||||
<>
|
||||
{past ? <HiveVersionBar occurredOn={past.occurredOn} onBack={() => {
|
||||
setPast(null);
|
||||
historyButtonRef.current?.focus();
|
||||
}} /> : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 sm:flex-row">
|
||||
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="absolute inset-0">
|
||||
<HiveVisualSlot
|
||||
layout={layout}
|
||||
catalog={catalog}
|
||||
partName={partName}
|
||||
onSelectPart={selectPart}
|
||||
onSelectFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
{view.openBoxId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="touch"
|
||||
className="absolute start-2 top-2"
|
||||
onClick={() => view.openBox(null)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
id={outlineId}
|
||||
className={
|
||||
showOutline
|
||||
? "max-h-40 min-h-0 overflow-y-auto sm:relative sm:max-h-none sm:w-72 sm:shrink-0 sm:overflow-visible"
|
||||
: "sr-only"
|
||||
}
|
||||
>
|
||||
<div className="sm:absolute sm:inset-0 sm:overflow-y-auto" data-testid="hive-outline-scroll">
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
state={shownState}
|
||||
catalog={catalog}
|
||||
canEdit={editable}
|
||||
announcement={announcement}
|
||||
onEditPart={editPart}
|
||||
onEditFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showRebuild && !offline ? (
|
||||
<Link
|
||||
href={`/colonies/${encodeURIComponent(colonyId)}/hive`}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
Umbauen
|
||||
</Link>
|
||||
) : null}
|
||||
{showRebuild && offline ? (
|
||||
<Button size="touch" disabled aria-describedby="hive-rebuild-hint">
|
||||
Umbauen
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
aria-pressed={showOutline}
|
||||
aria-controls={outlineId}
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
>
|
||||
<ListTree aria-hidden />
|
||||
Aufbau als Liste
|
||||
</Button>
|
||||
<Button ref={historyButtonRef} type="button" variant="outline" size="touch" onClick={() => setHistoryOpen(true)}>
|
||||
<History aria-hidden />
|
||||
Verlauf
|
||||
</Button>
|
||||
</div>
|
||||
{showRebuild && offline ? (
|
||||
<p id="hive-rebuild-hint" className="text-xs text-muted-foreground">
|
||||
{OFFLINE_REBUILD_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
<HiveSheetShell open={historyOpen} onOpenChange={setHistoryOpen} title="Verlauf">
|
||||
<HiveHistory
|
||||
colonyId={colonyId}
|
||||
state={setup.state}
|
||||
catalog={catalog}
|
||||
onShowVersion={(state, occurredOn) => {
|
||||
setPast({ state, occurredOn });
|
||||
setHistoryOpen(false);
|
||||
}}
|
||||
/>
|
||||
</HiveSheetShell>
|
||||
{frameTarget ? (
|
||||
<FrameSheet
|
||||
open={frameOpen}
|
||||
onOpenChange={setFrameOpen}
|
||||
state={workingState}
|
||||
catalog={catalog}
|
||||
boxId={frameTarget.boxId}
|
||||
slot={frameTarget.slot}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
) : null}
|
||||
{partTarget ? (
|
||||
<PartSheet
|
||||
open={partOpen}
|
||||
onOpenChange={setPartOpen}
|
||||
state={workingState}
|
||||
catalog={catalog}
|
||||
partId={partTarget.id}
|
||||
startWithEvaporator={partTarget.evaporator}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
) : null}
|
||||
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={mutation.resolveConflict} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { HiveConflictSheet } from "./hive-conflict-sheet";
|
||||
import type { HiveConflict } from "./use-hive-mutation";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const current: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 1, fondantKg: 0 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
|
||||
|
||||
const conflictOf = (state: HiveState, userOps: HiveOp[] = ops): HiveConflict => ({
|
||||
current: { version: 5, state },
|
||||
ops: userOps,
|
||||
preview: applyHiveOps(state, userOps, catalog),
|
||||
});
|
||||
|
||||
describe("HiveConflictSheet", () => {
|
||||
it("renders nothing without a conflict", () => {
|
||||
render(<HiveConflictSheet conflict={null} catalog={catalog} onResolve={vi.fn()} />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the preview of the own changes on the current state", () => {
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Flüssigfutter 3 l/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Erneut speichern" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("names the operation and the reason when the changes no longer fit", () => {
|
||||
const gone: HiveState = { ...current, stack: current.stack.filter((p) => p.id !== "fe") };
|
||||
render(<HiveConflictSheet conflict={conflictOf(gone)} catalog={catalog} onResolve={vi.fn()} />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("passt nicht mehr");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("gibt es nicht mehr");
|
||||
expect(screen.getByRole("button", { name: "Erneut speichern" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("resolves with retry and with discard", async () => {
|
||||
const onResolve = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
|
||||
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
|
||||
expect(onResolve).toHaveBeenLastCalledWith("retry");
|
||||
await user.click(screen.getByRole("button", { name: "Verwerfen" }));
|
||||
expect(onResolve).toHaveBeenLastCalledWith("discard");
|
||||
});
|
||||
|
||||
it("shows a failed retry inline", async () => {
|
||||
const onResolve = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const user = userEvent.setup();
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
|
||||
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
|
||||
});
|
||||
|
||||
it("ignores closing with Escape while a request is running", async () => {
|
||||
let finish: () => void = () => undefined;
|
||||
const onResolve = vi.fn().mockReturnValue(new Promise<void>((r) => (finish = r)));
|
||||
const user = userEvent.setup();
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
|
||||
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
expect(onResolve).toHaveBeenCalledWith("retry");
|
||||
finish();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import type { HiveCatalog } from "@/lib/hive/types";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import type { HiveConflict } from "./use-hive-mutation";
|
||||
import { SAVE_FAILED_TEXT } from "./use-sheet-submit";
|
||||
|
||||
export interface HiveConflictSheetProps {
|
||||
conflict: HiveConflict | null;
|
||||
catalog: HiveCatalog;
|
||||
onResolve: (action: "retry" | "discard") => Promise<void>;
|
||||
}
|
||||
|
||||
/** Spec section 11: the hive changed meanwhile; the own ops are shown on the current state. */
|
||||
export function HiveConflictSheet({ conflict, catalog, onResolve }: HiveConflictSheetProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function resolve(action: "retry" | "discard") {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onResolve(action);
|
||||
} catch {
|
||||
setError(SAVE_FAILED_TEXT);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const preview = conflict?.preview;
|
||||
const failedOp = conflict && preview && !preview.ok ? conflict.ops.slice(preview.opIndex, preview.opIndex + 1) : [];
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={conflict !== null}
|
||||
onOpenChange={(open) => {
|
||||
// Closing counts as discard, but not while a request is running.
|
||||
if (!open && !busy) void resolve("discard");
|
||||
}}
|
||||
title="Die Beute wurde inzwischen geändert"
|
||||
description="Jemand anderes hat den Aufbau verändert. Deine Änderung wurde noch nicht gespeichert."
|
||||
>
|
||||
{conflict && preview ? (
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
{preview.ok ? (
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Deine Änderung auf dem neuen Stand: </span>
|
||||
{describeHiveOps(conflict.ops, conflict.current.state, catalog)}
|
||||
</p>
|
||||
) : (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Deine Änderung passt nicht mehr:{" "}
|
||||
{describeHiveOps(failedOp, conflict.current.state, catalog) || "Schritt " + (preview.opIndex + 1)}.
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{hiveReasonText(preview.reason)}</span>
|
||||
</p>
|
||||
)}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
disabled={!preview.ok || busy}
|
||||
onClick={() => void resolve("retry")}
|
||||
>
|
||||
Erneut speichern
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" disabled={busy} onClick={() => void resolve("discard")}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
) : null}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { hiveFactsText, shortGermanDay, sourceLabel } from "@/lib/hive/summary";
|
||||
import type { HiveChangeSource, HiveFacts } from "@/lib/hive/types";
|
||||
|
||||
export interface HiveFactsLineProps {
|
||||
facts: HiveFacts;
|
||||
/** The latest change; omitted while a past state is shown. */
|
||||
lastChange?: { occurredOn: string; source: HiveChangeSource } | null;
|
||||
}
|
||||
|
||||
/** „2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin“ and „Stand: 12.10. aus Durchsicht“ (spec 9.4). */
|
||||
export function HiveFactsLine({ facts, lastChange }: HiveFactsLineProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">{hiveFactsText(facts)}</p>
|
||||
{lastChange ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stand: {shortGermanDay(lastChange.occurredOn)} aus {sourceLabel(lastChange.source)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { 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 { fetchHiveAt, fetchHiveHistory } from "@/lib/hive/api";
|
||||
import { inlineCatalog } from "@/lib/hive/test-utils";
|
||||
import { HiveHistory, HiveVersionBar } from "./hive-history";
|
||||
import { historyItem, sampleState } from "./test-fixtures";
|
||||
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHiveHistory: vi.fn(),
|
||||
fetchHiveAt: vi.fn(),
|
||||
}));
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function renderHistory(onShowVersion = vi.fn()) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveHistory colonyId="colony-1" state={sampleState()} catalog={catalog} onShowVersion={onShowVersion} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onShowVersion;
|
||||
}
|
||||
|
||||
describe("HiveHistory", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchHiveHistory).mockReset();
|
||||
vi.mocked(fetchHiveAt).mockReset();
|
||||
});
|
||||
|
||||
it("lists a change with day, source and the text from its operations", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
renderHistory();
|
||||
|
||||
const row = await screen.findByRole("button", { name: /12\.10\./ });
|
||||
expect(row).toHaveTextContent("Durchsicht");
|
||||
expect(row).toHaveTextContent("Flüssigfutter 3 l");
|
||||
});
|
||||
|
||||
it("marks a change entered after the fact with „nachgetragen am“", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({
|
||||
items: [historyItem({ occurredOn: "2026-10-10", createdAt: "2026-10-15T09:00:00.000Z" })],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderHistory();
|
||||
|
||||
expect(await screen.findByText("nachgetragen am 15.10.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not mark a change entered on its own day", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
renderHistory();
|
||||
|
||||
await screen.findByRole("button", { name: /12\.10\./ });
|
||||
expect(screen.queryByText(/nachgetragen/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads the state of that day when a row is chosen", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
const at = { version: 3, state: sampleState(), facts: {} as never, occurredOn: "2026-10-12" };
|
||||
vi.mocked(fetchHiveAt).mockResolvedValue(at);
|
||||
const onShowVersion = renderHistory();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /12\.10\./ }));
|
||||
|
||||
expect(fetchHiveAt).toHaveBeenCalledWith("colony-1", "2026-10-12");
|
||||
expect(onShowVersion).toHaveBeenCalledWith(at.state, "2026-10-12");
|
||||
});
|
||||
|
||||
it("says so when the state of that day cannot be loaded", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
vi.mocked(fetchHiveAt).mockRejectedValue(new Error("offline"));
|
||||
const onShowVersion = renderHistory();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /12\.10\./ }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Stand konnte nicht geladen werden.");
|
||||
expect(onShowVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the next page with the cursor", async () => {
|
||||
vi.mocked(fetchHiveHistory)
|
||||
.mockResolvedValueOnce({ items: [historyItem()], nextCursor: "cur-1" })
|
||||
.mockResolvedValueOnce({
|
||||
items: [historyItem({ id: "c0", version: 2, occurredOn: "2026-09-01", source: "INIT", ops: [] })],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderHistory();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
|
||||
|
||||
expect(fetchHiveHistory).toHaveBeenLastCalledWith("colony-1", "cur-1");
|
||||
const row = await screen.findByRole("button", { name: /01\.09\./ });
|
||||
expect(row).toHaveTextContent("Beute angelegt");
|
||||
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when nothing was changed yet", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [], nextCursor: null });
|
||||
renderHistory();
|
||||
|
||||
expect(await screen.findByText("Noch keine Änderungen.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveVersionBar", () => {
|
||||
it("shows the day and a way back to today", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<HiveVersionBar occurredOn="2026-08-12" onBack={onBack} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/Stand vom 12\.08\./);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { fetchHiveAt, fetchHiveHistory, hiveKeys } from "@/lib/hive/api";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { shortGermanDay, sourceLabel } from "@/lib/hive/summary";
|
||||
import type { HiveCatalog, HiveChangeListItem, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
|
||||
const LOAD_FAILED_TEXT = "Stand konnte nicht geladen werden.";
|
||||
|
||||
/** History pages of a colony; the card reads the first item for „Stand: …“, the list reads all pages. */
|
||||
export function useHiveHistory(colonyId: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...hiveKeys.history(colonyId), "pages"],
|
||||
queryFn: ({ pageParam }) => fetchHiveHistory(colonyId, pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function textOf(item: HiveChangeListItem, state: HiveState, catalog: HiveCatalog): string {
|
||||
const ops = Array.isArray(item.ops) ? (item.ops as HiveOp[]) : [];
|
||||
// Items carry no state before; the current state names parts that still exist.
|
||||
const text = describeHiveOps(ops, state, catalog);
|
||||
if (text) return text;
|
||||
return item.source === "INIT" ? "Beute angelegt" : "Änderung";
|
||||
}
|
||||
|
||||
export interface HiveHistoryProps {
|
||||
colonyId: string;
|
||||
/** The current state, only used to name parts in the texts. */
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
/** The state after the chosen change (read-only in the card). */
|
||||
onShowVersion: (state: HiveState, occurredOn: string) => void;
|
||||
}
|
||||
|
||||
/** Paged list of the hive's changes (spec 9.10). */
|
||||
export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHistoryProps) {
|
||||
const history = useHiveHistory(colonyId);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
async function show(item: HiveChangeListItem) {
|
||||
setLoadingId(item.id);
|
||||
setError(null);
|
||||
try {
|
||||
const view = await fetchHiveAt(colonyId, item.occurredOn);
|
||||
onShowVersion(view.state, view.occurredOn);
|
||||
} catch {
|
||||
setError(LOAD_FAILED_TEXT);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (history.isLoading) {
|
||||
return <p className="px-4 pb-4 text-sm text-muted-foreground">Verlauf wird geladen…</p>;
|
||||
}
|
||||
if (history.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 px-4 pb-4">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Verlauf konnte nicht geladen werden.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => void history.refetch()}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <p className="px-4 pb-4 text-sm text-muted-foreground">Noch keine Änderungen.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const enteredOn = berlinCalendarDay(item.createdAt);
|
||||
const isLate = item.occurredOn < enteredOn;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => void show(item)}
|
||||
className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
|
||||
</span>
|
||||
<span>{textOf(item, state, catalog)}</span>
|
||||
{isLate ? (
|
||||
<span className="text-xs text-muted-foreground">nachgetragen am {shortGermanDay(enteredOn)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{history.hasNextPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
Mehr laden
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HiveVersionBarProps {
|
||||
occurredOn: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** „Stand vom 12.08. · Zurück zu heute“: shown while a past state is displayed read-only. */
|
||||
export function HiveVersionBar({ occurredOn, onBack }: HiveVersionBarProps) {
|
||||
return (
|
||||
<div role="status" className="flex flex-wrap items-center gap-2 rounded-lg bg-muted px-3 py-1 text-sm">
|
||||
<span>Stand vom {shortGermanDay(occurredOn)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<Button type="button" variant="link" size="touch" onClick={onBack}>
|
||||
Zurück zu heute
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { 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 { ApiError } from "@/lib/auth/api-client";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createHive, fetchHive, fetchHiveSystems, fetchHiveTemplate } from "@/lib/hive/api";
|
||||
import { frames, part, templateState } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveInit } from "./hive-init";
|
||||
import { colonyFixture, setupView, systemDto } from "./test-fixtures";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHiveSystems: vi.fn(),
|
||||
fetchHiveTemplate: vi.fn(),
|
||||
fetchHive: vi.fn(),
|
||||
createHive: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
|
||||
}));
|
||||
|
||||
function renderInit(canEdit = true) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveInit colonyId="colony-1" colony={colonyFixture()} canEdit={canEdit} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("HiveInit", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["colonies:update"];
|
||||
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
|
||||
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
|
||||
vi.mocked(fetchHive).mockReset();
|
||||
vi.mocked(createHive).mockReset();
|
||||
vi.mocked(fetchColonies)
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
colonyFixture(),
|
||||
colonyFixture({ id: "colony-2", code: "V-02", apiaryId: "apiary-2" }),
|
||||
colonyFixture({ id: "colony-3", code: "V-03", apiaryId: "apiary-1" }),
|
||||
colonyFixture({ id: "colony-4", code: "V-04", status: "DISSOLVED" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows readers only the text, without loading the template", async () => {
|
||||
permissions = [];
|
||||
renderInit(false);
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Wie anderes Volk" })).not.toBeInTheDocument();
|
||||
expect(fetchHiveTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews the template of the first active system and creates it on „Übernehmen“", async () => {
|
||||
vi.mocked(createHive).mockResolvedValue(setupView(templateState()));
|
||||
const client = renderInit();
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit Zarge/ })).toBeInTheDocument();
|
||||
expect(fetchHiveTemplate).toHaveBeenCalledWith("sys");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(createHive).toHaveBeenCalledWith(
|
||||
"colony-1",
|
||||
{ systemId: "sys", state: templateState(), occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
|
||||
expect.any(String),
|
||||
);
|
||||
await vi.waitFor(() => expect(client.getQueryData(["colonies", "colony-1", "hive"])).toBeDefined());
|
||||
});
|
||||
|
||||
it("explains a missing template and links to the settings only for hive-systems:manage", async () => {
|
||||
vi.mocked(fetchHiveTemplate).mockRejectedValue(
|
||||
new ApiError(409, "Vorlage fehlt", { code: "TEMPLATE_NOT_SET" }),
|
||||
);
|
||||
permissions = ["colonies:update", "hive-systems:manage"];
|
||||
renderInit();
|
||||
|
||||
expect(await screen.findByText("Für dieses Beutensystem fehlt eine Vorlage.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Einstellungen/ })).toHaveAttribute("href", "/settings/hive-systems");
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows no settings link without the permission", async () => {
|
||||
vi.mocked(fetchHiveTemplate).mockRejectedValue(
|
||||
new ApiError(409, "Vorlage fehlt", { code: "TEMPLATE_NOT_SET" }),
|
||||
);
|
||||
renderInit();
|
||||
|
||||
await screen.findByText("Für dieses Beutensystem fehlt eine Vorlage.");
|
||||
expect(screen.queryByRole("link", { name: /Einstellungen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies another colony: same apiary first, empty combs, fresh ids", async () => {
|
||||
const other: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10, "BROOD") }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
vi.mocked(fetchHive).mockResolvedValue(setupView(other, "colony-3"));
|
||||
vi.mocked(createHive).mockResolvedValue(setupView(other));
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wie anderes Volk" }));
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
// The colony itself and ended colonies are not offered; V-03 shares the apiary and comes first.
|
||||
expect(options.map((option) => option.textContent)).toEqual(["V-03", "V-02"]);
|
||||
await userEvent.click(options[0]);
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
|
||||
expect(fetchHive).toHaveBeenCalledWith("colony-3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
const input = vi.mocked(createHive).mock.calls[0][1];
|
||||
const box = input.state.stack[1];
|
||||
expect(box.frames?.every((frame) => frame.type === "EMPTY_COMB")).toBe(true);
|
||||
expect(input.state.stack.map((p) => p.id)).not.toContain("b");
|
||||
});
|
||||
|
||||
it("tells when the other colony has no hive yet", async () => {
|
||||
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wie anderes Volk" }));
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.click((await screen.findAllByRole("option"))[0]);
|
||||
|
||||
expect(await screen.findByText("Dieses Volk hat noch keine Beute.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an error when creating fails and keeps the button", async () => {
|
||||
vi.mocked(createHive).mockRejectedValue(new ApiError(500, "Fehler", {}));
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht angelegt werden.");
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("reuses the idempotency key when the same proposal is pressed again after an error", async () => {
|
||||
vi.mocked(createHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {})).mockResolvedValue(setupView());
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
await screen.findByRole("alert");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
const keys = vi.mocked(createHive).mock.calls.map((call) => call[2]);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0]).toBe(keys[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
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 { berlinCalendarDay } from "@/lib/dates";
|
||||
import { createHive, fetchHive, fetchHiveTemplate, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
|
||||
import { copyHiveFrom } from "@/lib/hive/copy";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { HiveSvg } from "./hive-svg";
|
||||
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const NO_TEMPLATE_TEXT = "Für dieses Beutensystem fehlt eine Vorlage.";
|
||||
const OTHER_WITHOUT_HIVE_TEXT = "Dieses Volk hat noch keine Beute.";
|
||||
const CREATE_FAILED_TEXT = "Beute konnte nicht angelegt werden.";
|
||||
const LOAD_FAILED_TEXT = "Vorschau konnte nicht geladen werden.";
|
||||
const SETTINGS_HREF = "/settings/hive-systems";
|
||||
|
||||
/** Candidates for „Wie anderes Volk“: living colonies of the same apiary first, then all others. */
|
||||
function otherColonies(colonies: Colony[] | undefined, colony: Colony): Colony[] {
|
||||
const living = (colonies ?? []).filter((candidate) => isLivingColony(candidate) && candidate.id !== colony.id);
|
||||
return [
|
||||
...living.filter((candidate) => candidate.apiaryId === colony.apiaryId),
|
||||
...living.filter((candidate) => candidate.apiaryId !== colony.apiaryId),
|
||||
];
|
||||
}
|
||||
|
||||
/** Read-only picture of a state that is not saved yet. */
|
||||
function Preview({ state, catalog }: { state: HiveState; catalog: HiveCatalog }) {
|
||||
const [store] = useState(createHiveViewStore);
|
||||
const layout = useMemo(() => hiveLayout(state, catalog), [state, catalog]);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveSvg layout={layout} partName={(id) => partDisplayName(state, catalog, id)} />
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface Proposal {
|
||||
systemId: string;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
}
|
||||
|
||||
export interface HiveInitProps {
|
||||
colonyId: string;
|
||||
colony: Colony;
|
||||
/** colonies:update on a living colony; readers only see the text. */
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
/** Colony without a hive (spec 11): preview of the template or of another colony, then „Übernehmen“. */
|
||||
export function HiveInit({ colonyId, colony, canEdit }: HiveInitProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [otherId, setOtherId] = useState("");
|
||||
|
||||
const systems = useHiveSystems();
|
||||
const system = systems.data?.find((candidate) => candidate.active) ?? systems.data?.[0];
|
||||
const templateCatalog = useSystemCatalog(systems.data, system?.id);
|
||||
|
||||
const template = useQuery({
|
||||
queryKey: ["hive-systems", system?.id, "template"],
|
||||
queryFn: () => fetchHiveTemplate(system?.id ?? ""),
|
||||
enabled: canEdit && !copying && system !== undefined,
|
||||
});
|
||||
const colonies = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies(), enabled: canEdit && copying });
|
||||
const other = useQuery({
|
||||
queryKey: hiveKeys.detail(otherId),
|
||||
queryFn: () => fetchHive(otherId),
|
||||
enabled: canEdit && copying && otherId !== "",
|
||||
});
|
||||
const otherCatalog = useSystemCatalog(systems.data, other.data?.systemId);
|
||||
|
||||
const proposal = useMemo<Proposal | null>(() => {
|
||||
if (!copying) {
|
||||
return system && templateCatalog && template.data
|
||||
? { systemId: system.id, state: template.data, catalog: templateCatalog }
|
||||
: null;
|
||||
}
|
||||
if (!other.data || !otherCatalog) return null;
|
||||
return {
|
||||
systemId: other.data.systemId,
|
||||
state: copyHiveFrom(other.data.state, otherCatalog),
|
||||
catalog: otherCatalog,
|
||||
};
|
||||
}, [copying, system, templateCatalog, template.data, other.data, otherCatalog]);
|
||||
|
||||
// The same proposed content keeps its key, so a repeat press after a network error cannot create twice.
|
||||
const keyRef = useRef<{ signature: string; key: string } | null>(null);
|
||||
function keyFor(current: Proposal): string {
|
||||
const signature = JSON.stringify([current.systemId, current.state]);
|
||||
if (keyRef.current?.signature !== signature) keyRef.current = { signature, key: newIdempotencyKey() };
|
||||
return keyRef.current.key;
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (current: Proposal) =>
|
||||
createHive(
|
||||
colonyId,
|
||||
{
|
||||
systemId: current.systemId,
|
||||
state: current.state,
|
||||
occurredOn: berlinCalendarDay(new Date().toISOString()),
|
||||
},
|
||||
keyFor(current),
|
||||
),
|
||||
onSuccess: (view) => {
|
||||
queryClient.setQueryData(hiveKeys.detail(colonyId), view);
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) });
|
||||
},
|
||||
onError: (error) => {
|
||||
// Someone created it meanwhile: show theirs.
|
||||
if (hiveErrorCode(error) === "HIVE_EXISTS") {
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(colonyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Noch keine Beute angelegt</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const templateMissing = !copying && hiveErrorCode(template.error) === "TEMPLATE_NOT_SET";
|
||||
const otherHasNoHive = copying && hiveErrorCode(other.error) === "HIVE_NOT_FOUND";
|
||||
const loadFailed =
|
||||
systems.isError ||
|
||||
(!copying && template.isError && !templateMissing) ||
|
||||
(copying && other.isError && !otherHasNoHive);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Noch keine Beute angelegt</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copying ? "Aufbau eines anderen Volks, ohne Namen und Füllstände." : "Startaufbau aus der Vorlage des Beutensystems."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="absolute inset-0">
|
||||
{proposal ? <Preview state={proposal.state} catalog={proposal.catalog} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{templateMissing ? (
|
||||
<p role="status" className="text-sm">
|
||||
{NO_TEMPLATE_TEXT}{" "}
|
||||
{hasPermission("hive-systems:manage") ? (
|
||||
<Link href={SETTINGS_HREF} className="underline underline-offset-2">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{otherHasNoHive ? <p className="text-sm">{OTHER_WITHOUT_HIVE_TEXT}</p> : null}
|
||||
{loadFailed ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{LOAD_FAILED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
{copying ? (
|
||||
<Field id="hive-init-other" label="Volk" required>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<RecordSelect
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
value={otherId}
|
||||
onChange={setOtherId}
|
||||
items={colonies.data ? otherColonies(colonies.data, colony) : undefined}
|
||||
getLabel={colonyOptionLabel}
|
||||
placeholder={colonies.isLoading ? "Völker werden geladen…" : "Volk wählen"}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
{create.isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{CREATE_FAILED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
disabled={proposal === null || create.isPending}
|
||||
onClick={() => proposal && create.mutate(proposal)}
|
||||
>
|
||||
Übernehmen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => {
|
||||
setCopying(!copying);
|
||||
setOtherId("");
|
||||
create.reset();
|
||||
}}
|
||||
>
|
||||
{copying ? "Aus Vorlage" : "Wie anderes Volk"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", {
|
||||
frames: [
|
||||
{ slot: 1, type: "BROOD", name: "Königin" },
|
||||
{ slot: 2, type: "HONEY" },
|
||||
],
|
||||
}),
|
||||
part("b2", "box", { frames: [{ slot: 1, type: "FOUNDATION" }] }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 0 } }),
|
||||
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof HiveOutline>> = {}) {
|
||||
const store = createHiveViewStore();
|
||||
const onEditPart = vi.fn();
|
||||
const onEditFrame = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const view = render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={onEditPart}
|
||||
onEditFrame={onEditFrame}
|
||||
{...props}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
return { store, onEditPart, onEditFrame, user, ...view };
|
||||
}
|
||||
|
||||
const names = () => screen.getAllByRole("treeitem").map((item) => item.textContent);
|
||||
|
||||
describe("HiveOutline", () => {
|
||||
it("is a labelled tree listing the roof first and the floor last", () => {
|
||||
setup();
|
||||
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
|
||||
const items = names();
|
||||
expect(items[0]).toContain("Dach");
|
||||
expect(items[1]).toContain("Holzdeckel");
|
||||
expect(items[items.length - 1]).toContain("Boden");
|
||||
expect(items.findIndex((t) => t?.includes("Zarge 2"))).toBeLessThan(items.findIndex((t) => t?.includes("Zarge 1")));
|
||||
});
|
||||
|
||||
it("shows a short state hint per part", () => {
|
||||
setup();
|
||||
const text = names().join("|");
|
||||
expect(text).toContain("Futterzarge · 2,5 l / 0 kg");
|
||||
expect(text).toContain("Boden · Bausperre drin");
|
||||
expect(text).toContain("Holzdeckel · zu");
|
||||
});
|
||||
|
||||
it("marks boxes as expandable and keeps frames hidden until expanded", () => {
|
||||
setup();
|
||||
const box = screen.getByRole("treeitem", { name: /Zarge 1/ });
|
||||
expect(box).toHaveAttribute("aria-expanded", "false");
|
||||
expect(box).toHaveAttribute("aria-level", "1");
|
||||
expect(screen.queryByRole("treeitem", { name: /Brutwabe/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("has exactly one tabbable item (roving tabindex), the roof first", () => {
|
||||
setup();
|
||||
const tabbable = screen.getAllByRole("treeitem").filter((item) => item.getAttribute("tabindex") === "0");
|
||||
expect(tabbable).toHaveLength(1);
|
||||
expect(tabbable[0]).toHaveTextContent("Dach");
|
||||
});
|
||||
|
||||
it("moves with Down and Up and calls setHovered on focus", async () => {
|
||||
const { user, store } = setup();
|
||||
await user.tab();
|
||||
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
|
||||
expect(store.getState().hoveredId).toBe("r");
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus();
|
||||
expect(store.getState().hoveredId).toBe("l");
|
||||
await user.keyboard("{ArrowUp}");
|
||||
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("jumps with Home and End", async () => {
|
||||
const { user } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("treeitem", { name: /Boden/ })).toHaveFocus();
|
||||
await user.keyboard("{Home}");
|
||||
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("expands a box with Right, enters it with Right again and collapses with Left", async () => {
|
||||
const { user, store } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}");
|
||||
const box = screen.getByRole("treeitem", { name: /Zarge 2/ });
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(box).toHaveAttribute("aria-expanded", "true");
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
const frame = screen.getByRole("treeitem", { name: /R1 · Mittelwand/ });
|
||||
expect(frame).toHaveFocus();
|
||||
expect(frame).toHaveAttribute("aria-level", "2");
|
||||
expect(store.getState().hoveredFrame).toEqual({ boxId: "b2", slot: 1 });
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(box).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("lists frames as R<slot> · type · name", async () => {
|
||||
const { user } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
|
||||
expect(screen.getByRole("treeitem", { name: "R1 · Brutwabe · Königin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("treeitem", { name: "R2 · Honigwabe" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the box on Enter, expands it and edits a frame on Enter", async () => {
|
||||
const { user, store, onEditFrame } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}");
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(store.getState().openBoxId).toBe("b1");
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-expanded", "true");
|
||||
await user.keyboard("{ArrowRight}{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onEditFrame).toHaveBeenCalledWith("b1", 2);
|
||||
});
|
||||
|
||||
it("edits a part with state fields on Enter and ignores parts without", async () => {
|
||||
const { user, onEditPart } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onEditPart).not.toHaveBeenCalled();
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(onEditPart).toHaveBeenCalledWith("l");
|
||||
});
|
||||
|
||||
it("does not edit without canEdit but still opens boxes", async () => {
|
||||
const { user, store, onEditPart } = setup({ canEdit: false });
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(onEditPart).not.toHaveBeenCalled();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
|
||||
expect(store.getState().openBoxId).toBe("b2");
|
||||
});
|
||||
|
||||
it("expands the box when it is opened from elsewhere", () => {
|
||||
const { store } = setup();
|
||||
act(() => store.openBox("b1"));
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("treeitem", { name: /R1 · Brutwabe/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the announcement in a polite status region that is always present", () => {
|
||||
const { rerender, store } = setup();
|
||||
const region = screen.getByRole("status");
|
||||
expect(region).toHaveAttribute("aria-live", "polite");
|
||||
expect(region).not.toHaveClass("empty:hidden");
|
||||
rerender(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement="Rähmchen 1 in Zarge 1 ist jetzt Brutwabe."
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => {}}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Rähmchen 1 in Zarge 1 ist jetzt Brutwabe.");
|
||||
});
|
||||
|
||||
it("gives every item a touch target of at least 44 px", () => {
|
||||
setup();
|
||||
for (const item of screen.getAllByRole("treeitem")) expect(item).toHaveClass("min-h-11");
|
||||
});
|
||||
|
||||
it("clears hovering when focus leaves the tree", async () => {
|
||||
const { user, store } = setup();
|
||||
await user.tab();
|
||||
expect(store.getState().hoveredId).toBe("r");
|
||||
await user.tab();
|
||||
expect(store.getState().hoveredId).toBeNull();
|
||||
});
|
||||
|
||||
it("edits the floor on Enter and keeps its hint", async () => {
|
||||
const { user, onEditPart } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("treeitem", { name: /Boden · Bausperre drin/ })).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onEditPart).toHaveBeenCalledWith("f");
|
||||
});
|
||||
|
||||
it("offers Verdunster einsetzen as last child of a box without evaporator", async () => {
|
||||
const { user, onEditPart } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
|
||||
const action = screen.getByRole("treeitem", { name: /Verdunster einsetzen/ });
|
||||
expect(action).toHaveAttribute("aria-level", "2");
|
||||
expect(action.previousElementSibling).toHaveTextContent("R2 · Honigwabe");
|
||||
await user.click(action);
|
||||
expect(onEditPart).toHaveBeenCalledWith("b1");
|
||||
});
|
||||
|
||||
it("hides the add action without canEdit or when an evaporator exists", () => {
|
||||
const { unmount } = setup({ canEdit: false });
|
||||
act(() => {});
|
||||
expect(screen.queryByRole("treeitem", { name: /Verdunster einsetzen/ })).toBeNull();
|
||||
unmount();
|
||||
const withEvap: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("b1", "box", { frames: [], inserts: [part("e1", "evaporator")] })],
|
||||
};
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline state={withEvap} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("treeitem", { name: /Verdunster einsetzen/ })).toBeNull();
|
||||
expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the open box with aria-current", () => {
|
||||
const { store } = setup();
|
||||
act(() => store.openBox("b1"));
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
|
||||
it("moves focus to the nearest sibling when the focused item disappears", async () => {
|
||||
const { user, store, rerender } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus();
|
||||
const without: HiveState = { ...state, stack: state.stack.filter((p) => p.id !== "l") };
|
||||
rerender(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline state={without} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
expect(screen.getByRole("treeitem", { name: /Futterzarge/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
describe("imperative focus handle", () => {
|
||||
function withHandle(outlineState: HiveState = state) {
|
||||
const ref = createRef<HiveOutlineHandle>();
|
||||
const store = createHiveViewStore();
|
||||
render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
ref={ref}
|
||||
state={outlineState}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => {}}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
return ref;
|
||||
}
|
||||
|
||||
it("focusPart focuses the item of a visible part", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusPart("fe"));
|
||||
expect(screen.getByRole("treeitem", { name: /Futterzarge/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focusFrame expands the box and focuses the frame item", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusFrame("b1", 2));
|
||||
expect(screen.getByRole("treeitem", { name: /R2 · Honigwabe/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focusPart on an insert inside a collapsed box expands the box first", () => {
|
||||
const withEvap: HiveState = {
|
||||
...state,
|
||||
stack: state.stack.map((p) =>
|
||||
p.id === "b1" ? { ...p, inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] } : p,
|
||||
),
|
||||
};
|
||||
const ref = withHandle(withEvap);
|
||||
act(() => ref.current?.focusPart("ev"));
|
||||
expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focusPart on the hidden floor insert focuses its floor", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusPart("fi"));
|
||||
expect(screen.getByRole("treeitem", { name: /Boden/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("ignores an unknown id and leaves focus alone", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusPart("nope"));
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { FocusEvent, KeyboardEvent, Ref } from "react";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types";
|
||||
import { useHiveView } from "./use-hive-view";
|
||||
|
||||
// Accessible outline of the hive (WAI-ARIA tree, spec 9.6). It offers everything
|
||||
// the 3D view does: hover, open a box, edit a frame or a part with state.
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
interface OutlineNode {
|
||||
key: string;
|
||||
kind: "part" | "frame" | "action";
|
||||
/** Part id, or the box id for a frame. */
|
||||
id: string;
|
||||
slot?: number;
|
||||
level: number;
|
||||
parentKey: string | null;
|
||||
childCount: number;
|
||||
name: string;
|
||||
hint: string | null;
|
||||
isBox: boolean;
|
||||
isFloor: boolean;
|
||||
hasState: boolean;
|
||||
setSize: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
function fieldText(field: StateField, value: number | string | undefined): string {
|
||||
if (field.kind === "choice") {
|
||||
const index = field.options.indexOf(String(value));
|
||||
return index >= 0 ? field.optionLabels[index] : String(value ?? "");
|
||||
}
|
||||
const text = numberFormat.format(Number(value ?? 0));
|
||||
return field.unit ? `${text} ${field.unit}` : text;
|
||||
}
|
||||
|
||||
function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] {
|
||||
return (part.inserts ?? []).filter((insert) => catalog.partTypes.get(insert.partTypeId)?.kind !== "FLOOR_INSERT");
|
||||
}
|
||||
|
||||
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
|
||||
if (!type) return null;
|
||||
if (type.stateSchema.length > 0) {
|
||||
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).join(" / ");
|
||||
}
|
||||
if (type.kind === "BOX") {
|
||||
const count = part.frames?.length ?? 0;
|
||||
return count > 0 ? `${count} Rähmchen` : null;
|
||||
}
|
||||
const hasLock = (part.inserts ?? []).some(
|
||||
(insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT",
|
||||
);
|
||||
return hasLock ? "Bausperre drin" : null;
|
||||
}
|
||||
|
||||
const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …";
|
||||
|
||||
function buildNodes(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
expanded: ReadonlySet<string>,
|
||||
canEdit: boolean,
|
||||
): OutlineNode[] {
|
||||
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
|
||||
const nodes: OutlineNode[] = [];
|
||||
const topDown = [...state.stack].reverse();
|
||||
|
||||
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
const inserts = visibleInserts(part, catalog);
|
||||
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
|
||||
const isBox = type?.kind === "BOX";
|
||||
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
|
||||
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
|
||||
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
|
||||
nodes.push({
|
||||
key: part.id,
|
||||
kind: "part",
|
||||
id: part.id,
|
||||
level,
|
||||
parentKey,
|
||||
childCount,
|
||||
name: partDisplayName(state, catalog, part.id),
|
||||
hint: hintOf(part, type, catalog),
|
||||
isBox,
|
||||
isFloor: type?.kind === "FLOOR",
|
||||
hasState: (type?.stateSchema.length ?? 0) > 0,
|
||||
setSize,
|
||||
position,
|
||||
});
|
||||
if (!expanded.has(part.id)) return;
|
||||
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
|
||||
frames.forEach((frame, index) => {
|
||||
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
|
||||
nodes.push({
|
||||
key: `${part.id}#${frame.slot}`,
|
||||
kind: "frame",
|
||||
id: part.id,
|
||||
slot: frame.slot,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: label,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: inserts.length + index + 1,
|
||||
});
|
||||
});
|
||||
if (hasAddAction) {
|
||||
nodes.push({
|
||||
key: `${part.id}#add-evaporator`,
|
||||
kind: "action",
|
||||
id: part.id,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: ADD_EVAPORATOR_LABEL,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: childCount,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Moves focus to an item, e.g. back to the edited one when a sheet closes. */
|
||||
export interface HiveOutlineHandle {
|
||||
focusPart(partId: string): void;
|
||||
focusFrame(boxId: string, slot: number): void;
|
||||
}
|
||||
|
||||
/** Outline key and the part that must be expanded to show it; null when the part is unknown. */
|
||||
function focusTargetOf(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
partId: string,
|
||||
): { key: string; expand: string | null } | null {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === partId) return { key: partId, expand: null };
|
||||
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
|
||||
if (!insert) continue;
|
||||
// Floor inserts have no item of their own; their floor stands for them.
|
||||
const hidden = catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT";
|
||||
return hidden ? { key: part.id, expand: null } : { key: partId, expand: part.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface HiveOutlineProps {
|
||||
ref?: Ref<HiveOutlineHandle>;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
canEdit: boolean;
|
||||
/** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */
|
||||
announcement: string;
|
||||
onEditPart: (id: string) => void;
|
||||
onEditFrame: (boxId: string, slot: number) => void;
|
||||
}
|
||||
|
||||
export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEditPart, onEditFrame }: HiveOutlineProps) {
|
||||
const view = useHiveView();
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [seenOpenBox, setSeenOpenBox] = useState<string | null>(null);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const itemRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
||||
// A box opened elsewhere (3D, SVG) shows its frames here as well.
|
||||
if (view.openBoxId !== seenOpenBox) {
|
||||
setSeenOpenBox(view.openBoxId);
|
||||
if (view.openBoxId && !expanded.has(view.openBoxId)) setExpanded(new Set(expanded).add(view.openBoxId));
|
||||
}
|
||||
|
||||
const nodes = buildNodes(state, catalog, expanded, canEdit);
|
||||
const keySignature = nodes.map((node) => node.key).join("|");
|
||||
const previousNodes = useRef<OutlineNode[]>(nodes);
|
||||
const focusWithin = useRef(false);
|
||||
|
||||
// When the focused item disappears, focus moves to the nearest remaining
|
||||
// sibling, else its parent, else the first item.
|
||||
useLayoutEffect(() => {
|
||||
const before = previousNodes.current;
|
||||
previousNodes.current = nodes;
|
||||
if (!focusWithin.current || !activeKey) return;
|
||||
if (nodes.some((node) => node.key === activeKey)) return;
|
||||
const gone = before.findIndex((node) => node.key === activeKey);
|
||||
if (gone < 0) return;
|
||||
const present = new Set(nodes.map((node) => node.key));
|
||||
const parentKey = before[gone].parentKey;
|
||||
const siblings = (list: OutlineNode[]) => list.filter((n) => n.parentKey === parentKey && present.has(n.key));
|
||||
const next = siblings(before.slice(gone + 1))[0] ?? siblings(before.slice(0, gone)).reverse()[0];
|
||||
const target = next?.key ?? (parentKey && present.has(parentKey) ? parentKey : nodes[0]?.key);
|
||||
if (target) itemRefs.current.get(target)?.focus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keySignature]);
|
||||
|
||||
// Focus requested for an item that only exists after its parent expands.
|
||||
const pendingFocus = useRef<string | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const key = pendingFocus.current;
|
||||
pendingFocus.current = null;
|
||||
if (key) itemRefs.current.get(key)?.focus();
|
||||
});
|
||||
|
||||
/** Expands a collapsed parent and focuses the item once it is rendered; otherwise does nothing. */
|
||||
const requestFocusAfterExpand = (itemKey: string, parentKey: string | null): void => {
|
||||
if (!parentKey || expanded.has(parentKey)) return;
|
||||
pendingFocus.current = itemKey;
|
||||
setExpanded(new Set(expanded).add(parentKey));
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusPart(partId) {
|
||||
const target = focusTargetOf(state, catalog, partId);
|
||||
if (!target) return;
|
||||
const element = itemRefs.current.get(target.key);
|
||||
if (element) {
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
requestFocusAfterExpand(target.key, target.expand);
|
||||
},
|
||||
focusFrame(boxId, slot) {
|
||||
const key = `${boxId}#${slot}`;
|
||||
const element = itemRefs.current.get(key);
|
||||
if (element) {
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
requestFocusAfterExpand(key, boxId);
|
||||
},
|
||||
}));
|
||||
|
||||
const tabbableKey = nodes.some((node) => node.key === activeKey) ? activeKey : (nodes[0]?.key ?? null);
|
||||
|
||||
const toggle = (key: string, open: boolean): void => {
|
||||
const next = new Set(expanded);
|
||||
if (open) next.add(key);
|
||||
else next.delete(key);
|
||||
setExpanded(next);
|
||||
};
|
||||
|
||||
const focusKey = (key: string | undefined): void => {
|
||||
if (key) itemRefs.current.get(key)?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = (node: OutlineNode): void => {
|
||||
focusWithin.current = true;
|
||||
setActiveKey(node.key);
|
||||
if (node.kind === "frame") {
|
||||
view.setHovered(null);
|
||||
view.setHoveredFrame({ boxId: node.id, slot: node.slot ?? 0 });
|
||||
} else {
|
||||
view.setHovered(node.id);
|
||||
view.setHoveredFrame(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
// A removed item also loses focus; the microtask lets the layout effect see the flag first.
|
||||
const tree = event.currentTarget.parentElement;
|
||||
queueMicrotask(() => {
|
||||
if (!tree?.contains(document.activeElement)) focusWithin.current = false;
|
||||
});
|
||||
if (event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) return;
|
||||
view.setHovered(null);
|
||||
view.setHoveredFrame(null);
|
||||
};
|
||||
|
||||
const activateNode = (node: OutlineNode): void => {
|
||||
if (node.kind === "action") {
|
||||
if (canEdit) onEditPart(node.id);
|
||||
return;
|
||||
}
|
||||
if (node.kind === "frame") {
|
||||
if (canEdit) onEditFrame(node.id, node.slot ?? 0);
|
||||
return;
|
||||
}
|
||||
if (node.isBox) {
|
||||
view.openBox(node.id);
|
||||
toggle(node.id, true);
|
||||
return;
|
||||
}
|
||||
if (node.hasState || node.isFloor) {
|
||||
if (canEdit) onEditPart(node.id);
|
||||
return;
|
||||
}
|
||||
if (node.childCount > 0) toggle(node.key, !expanded.has(node.key));
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, node: OutlineNode, index: number): void => {
|
||||
const isOpen = expanded.has(node.key);
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
focusKey(nodes[index + 1]?.key);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
focusKey(nodes[index - 1]?.key);
|
||||
break;
|
||||
case "Home":
|
||||
focusKey(nodes[0]?.key);
|
||||
break;
|
||||
case "End":
|
||||
focusKey(nodes[nodes.length - 1]?.key);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (node.childCount === 0) break;
|
||||
if (!isOpen) toggle(node.key, true);
|
||||
else focusKey(nodes[index + 1]?.key);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (node.childCount > 0 && isOpen) toggle(node.key, false);
|
||||
else if (node.parentKey) focusKey(node.parentKey);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
activateNode(node);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div role="tree" aria-label="Aufbau der Beute" className="flex flex-col gap-1">
|
||||
{nodes.map((node, index) => {
|
||||
const isOpen = expanded.has(node.key);
|
||||
const isFrame = node.kind === "frame";
|
||||
const isCurrentBox = node.isBox && view.openBoxId === node.id;
|
||||
return (
|
||||
<div
|
||||
key={node.key}
|
||||
ref={(element) => {
|
||||
if (element) itemRefs.current.set(node.key, element);
|
||||
else itemRefs.current.delete(node.key);
|
||||
}}
|
||||
// aria-current marks the open box; aria-selected would suggest a selection model.
|
||||
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
|
||||
role="treeitem"
|
||||
aria-level={node.level}
|
||||
aria-setsize={node.setSize}
|
||||
aria-posinset={node.position}
|
||||
aria-expanded={node.childCount > 0 ? isOpen : undefined}
|
||||
aria-current={isCurrentBox ? "true" : undefined}
|
||||
tabIndex={node.key === tabbableKey ? 0 : -1}
|
||||
onFocus={() => handleFocus(node)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(event) => handleKeyDown(event, node, index)}
|
||||
onClick={() => {
|
||||
focusKey(node.key);
|
||||
activateNode(node);
|
||||
}}
|
||||
style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium"
|
||||
>
|
||||
<span className={isFrame ? "text-muted-foreground" : undefined}>
|
||||
{node.hint ? `${node.name} · ${node.hint}` : node.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
export interface HiveSheetShellProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Where focus goes once the sheet is closed (Escape, "Schließen" or after saving). */
|
||||
finalFocus?: ComponentProps<typeof SheetContent>["finalFocus"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The bottom sheet frame the hive sheets share: title, German close button, scroll area. */
|
||||
export function HiveSheetShell({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
finalFocus,
|
||||
children,
|
||||
}: HiveSheetShellProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
// The built-in close button is English; ours is German and 44 px high.
|
||||
showCloseButton={false}
|
||||
finalFocus={finalFocus}
|
||||
className="max-h-[90dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom) sm:mx-auto sm:max-w-lg"
|
||||
>
|
||||
<SheetHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</div>
|
||||
<SheetClose render={<Button variant="ghost" size="touch" />}>Schließen</SheetClose>
|
||||
</SheetHeader>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveSvg, projectPoint } from "./hive-svg";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b1", "box", {
|
||||
frames: [
|
||||
{ slot: 1, type: "BROOD", name: "Königin" },
|
||||
{ slot: 2, type: "HONEY" },
|
||||
{ slot: 3, type: "NONE" },
|
||||
],
|
||||
inserts: [part("e1", "evaporator")],
|
||||
}),
|
||||
part("b2", "box", { frames: frames(10) }),
|
||||
part("l", "inner-lid"),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
const layout = hiveLayout(state, catalog);
|
||||
|
||||
function renderSvg(ui: ReactNode, store = createHiveViewStore()) {
|
||||
return { store, ...render(<HiveViewProvider store={store}>{ui}</HiveViewProvider>) };
|
||||
}
|
||||
|
||||
describe("projectPoint", () => {
|
||||
it("applies the oblique projection with y down", () => {
|
||||
expect(projectPoint([2, 3, 4])).toEqual([4, -(3 - 0.35 * 4)]);
|
||||
expect(projectPoint([0, 0, 0])[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveSvg", () => {
|
||||
it("is an img with a summary label and never crops", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
const svg = container.querySelector("svg")!;
|
||||
expect(svg).toHaveAttribute("role", "img");
|
||||
expect(svg).toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
expect(svg.getAttribute("aria-label")).toBe("Beute mit 2 Zargen, Boden, Holzdeckel, Dach");
|
||||
const [x, y, w, h] = svg.getAttribute("viewBox")!.split(" ").map(Number);
|
||||
expect(w).toBeGreaterThan(0);
|
||||
expect(h).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(x + y)).toBe(true);
|
||||
});
|
||||
|
||||
it("fits the viewBox with 5 % padding around the projected bounds", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
const [x, , w] = container.querySelector("svg")!.getAttribute("viewBox")!.split(" ").map(Number);
|
||||
const left = Math.min(...layout.parts.map((p) => p.center[0] - p.size[0] / 2 + 0.5 * (-p.size[2] / 2)));
|
||||
const right = Math.max(...layout.parts.map((p) => p.center[0] + p.size[0] / 2 + 0.5 * (p.size[2] / 2)));
|
||||
const width = right - left;
|
||||
expect(x).toBeCloseTo(left - 0.05 * width, 5);
|
||||
expect(w).toBeCloseTo(width * 1.1, 5);
|
||||
});
|
||||
|
||||
it("draws one group per part with data-part-id and no frames while no box is open", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
const ids = [...container.querySelectorAll("g[data-part-id]")].map((g) => g.getAttribute("data-part-id"));
|
||||
expect(ids).toEqual(["f", "b1", "b2", "l", "r", "e1"]);
|
||||
expect(container.querySelectorAll("[data-frame-slot]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is not in the tab order and has no button roles without handlers", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
expect(container.querySelectorAll("[role=button]")).toHaveLength(0);
|
||||
expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("makes parts buttons with tabIndex -1 when onSelectPart is given", () => {
|
||||
const onSelectPart = vi.fn();
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} onSelectPart={onSelectPart} />);
|
||||
const group = container.querySelector('g[data-part-id="b2"]')!;
|
||||
expect(group).toHaveAttribute("role", "button");
|
||||
expect(group).toHaveAttribute("tabindex", "-1");
|
||||
fireEvent.click(group);
|
||||
expect(onSelectPart).toHaveBeenCalledWith("b2");
|
||||
});
|
||||
|
||||
it("draws frames only for the open box, each with its type word as title", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
|
||||
const slots = [...container.querySelectorAll("[data-frame-slot]")];
|
||||
expect(slots).toHaveLength(3);
|
||||
const titles = slots.map((el) => el.querySelector("title")!.textContent);
|
||||
expect(titles[0]).toContain("Brutwabe");
|
||||
expect(titles[0]).toContain("Königin");
|
||||
expect(titles[1]).toContain("Honigwabe");
|
||||
expect(titles[2]).toContain("leer");
|
||||
expect(container.querySelectorAll('[data-frame-box="b2"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("namespaces pattern ids and references them from the frames", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
const { container } = renderSvg(
|
||||
<>
|
||||
<HiveSvg layout={layout} />
|
||||
<HiveSvg layout={layout} />
|
||||
</>,
|
||||
store,
|
||||
);
|
||||
const ids = [...container.querySelectorAll("pattern")].map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
const fill = container.querySelector("[data-frame-slot='1'] rect")!.getAttribute("fill")!;
|
||||
const target = /url\(#(.+)\)/.exec(fill)![1];
|
||||
expect(container.querySelector(`pattern[id="${target}"]`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("selects frames when onSelectFrame is given", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
const onSelectFrame = vi.fn();
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} onSelectFrame={onSelectFrame} />, store);
|
||||
const frame = container.querySelector("[data-frame-slot='2']")!;
|
||||
expect(frame).toHaveAttribute("role", "button");
|
||||
expect(frame).toHaveAttribute("tabindex", "-1");
|
||||
fireEvent.click(frame);
|
||||
expect(onSelectFrame).toHaveBeenCalledWith("b1", 2);
|
||||
});
|
||||
|
||||
it("uses currentColor based strokes and no hard-coded colours", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i);
|
||||
expect(container.querySelector("path")?.getAttribute("class")).toContain("stroke-current");
|
||||
});
|
||||
|
||||
it("marks the hovered part", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHovered("l");
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
|
||||
expect(container.querySelector('g[data-part-id="l"]')).toHaveAttribute("data-hovered", "true");
|
||||
expect(container.querySelector('g[data-part-id="r"]')).not.toHaveAttribute("data-hovered");
|
||||
});
|
||||
|
||||
it("renders an empty hive without crashing", () => {
|
||||
renderSvg(<HiveSvg layout={hiveLayout({ schemaVersion: 1, stack: [] }, catalog)} />);
|
||||
expect(screen.getByRole("img")).toHaveAttribute("aria-label", "Beute ohne Teile");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
||||
import type { FrameType } from "@/lib/hive/types";
|
||||
import { useHiveView } from "./use-hive-view";
|
||||
|
||||
// SVG view of the hive: fallback without WebGL, print, and the L2 visual.
|
||||
// Oblique projection of the shared layout (1 unit = 100 mm, y up). Strokes use
|
||||
// currentColor so forced-colors and print follow the surrounding text colour.
|
||||
|
||||
const SKEW_X = 0.5;
|
||||
const SKEW_Y = 0.35;
|
||||
const PADDING = 0.05;
|
||||
const FRAME_FILL_RATIO = 0.9;
|
||||
const PATTERN_TILE = 0.16;
|
||||
const EMPTY_VIEW_BOX = "0 0 1 1";
|
||||
|
||||
type Point = [number, number];
|
||||
|
||||
export function projectPoint([x, y, z]: Vec3): Point {
|
||||
return [x + SKEW_X * z, -(y - SKEW_Y * z)];
|
||||
}
|
||||
|
||||
const fmt = (value: number): string => Number(value.toFixed(4)).toString();
|
||||
|
||||
function polygon(points: Vec3[]): string {
|
||||
return (
|
||||
points
|
||||
.map((point, index) => {
|
||||
const [x, y] = projectPoint(point);
|
||||
return `${index === 0 ? "M" : "L"}${fmt(x)} ${fmt(y)}`;
|
||||
})
|
||||
.join(" ") + " Z"
|
||||
);
|
||||
}
|
||||
|
||||
function corners(box: { center: Vec3; size: Vec3 }): Vec3[] {
|
||||
const [cx, cy, cz] = box.center;
|
||||
const [w, h, d] = box.size;
|
||||
const result: Vec3[] = [];
|
||||
for (const dx of [-1, 1]) for (const dy of [-1, 1]) for (const dz of [-1, 1]) {
|
||||
result.push([cx + (dx * w) / 2, cy + (dy * h) / 2, cz + (dz * d) / 2]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function viewBoxOf(layout: HiveLayout): string {
|
||||
const points = [...layout.parts, ...layout.inserts].flatMap((part) => corners(part).map(projectPoint));
|
||||
if (points.length === 0) return EMPTY_VIEW_BOX;
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
const minX = Math.min(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const width = Math.max(...xs) - minX;
|
||||
const height = Math.max(...ys) - minY;
|
||||
return [minX - PADDING * width, minY - PADDING * height, width * (1 + 2 * PADDING), height * (1 + 2 * PADDING)]
|
||||
.map(String)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** "Beute mit 2 Zargen, Boden, Dach": boxes first, then the rest bottom to top. */
|
||||
function summaryOf(layout: HiveLayout): string {
|
||||
if (layout.parts.length === 0) return "Beute ohne Teile";
|
||||
const counts = new Map<string, number>();
|
||||
for (const part of layout.parts) {
|
||||
const label = PART_KIND_LABELS[part.kind];
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
const boxLabel = PART_KIND_LABELS.BOX;
|
||||
const labels = [...counts.keys()].sort((a, b) => Number(b === boxLabel) - Number(a === boxLabel));
|
||||
const items = labels.map((label) => {
|
||||
const count = counts.get(label) ?? 1;
|
||||
if (count === 1) return label;
|
||||
return label === boxLabel ? `${count} Zargen` : `${count}× ${label}`;
|
||||
});
|
||||
return `Beute mit ${items.join(", ")}`;
|
||||
}
|
||||
|
||||
function framePitch(frame: FrameBox, all: FrameBox[]): number {
|
||||
const xs = all.filter((other) => other.boxId === frame.boxId).map((other) => other.center[0]).sort((a, b) => a - b);
|
||||
const gaps = xs.slice(1).map((x, index) => x - xs[index]);
|
||||
return gaps.length > 0 ? Math.min(...gaps) : frame.size[0];
|
||||
}
|
||||
|
||||
// ---- Frame patterns: one per type, so the type never rides on colour alone ----
|
||||
|
||||
const T = PATTERN_TILE;
|
||||
const PATTERNS: Record<Exclude<FrameType, "NONE">, ReactNode> = {
|
||||
BROOD: <circle cx={T / 2} cy={T / 2} r={0.035} className="fill-current stroke-none" />,
|
||||
DRONE: <circle cx={T / 2} cy={T / 2} r={0.06} className="fill-current stroke-none" />,
|
||||
HONEY: <path d={`M0 ${T} L${T} 0`} />,
|
||||
POLLEN: <path d={`M${T / 2} 0 V${T} M0 ${T / 2} H${T}`} />,
|
||||
FEED: <path d={`M0 ${T / 2} H${T}`} />,
|
||||
FOUNDATION: <path d={`M${T / 2} 0 V${T}`} />,
|
||||
EMPTY_COMB: <circle cx={T / 2} cy={T / 2} r={0.05} className="fill-none" />,
|
||||
DRONE_FRAME: <path d={`M0 0 L${T} ${T} M0 ${T} L${T} 0`} />,
|
||||
DIVIDER: <rect width={T} height={T} className="fill-current opacity-60 stroke-none" />,
|
||||
};
|
||||
|
||||
const PATTERN_TYPES = Object.keys(PATTERNS) as Exclude<FrameType, "NONE">[];
|
||||
|
||||
// Colour comes from the <svg>; only hover changes it, never in print.
|
||||
const HOVER_COLOR = "data-[hovered=true]:text-holo-edge-strong print:data-[hovered=true]:text-black";
|
||||
|
||||
const edge = "fill-none stroke-current [vector-effect:non-scaling-stroke]";
|
||||
|
||||
function activate(event: KeyboardEvent, action: () => void): void {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
|
||||
export interface HiveSvgProps {
|
||||
layout: HiveLayout;
|
||||
onSelectPart?: (id: string) => void;
|
||||
onSelectFrame?: (boxId: string, slot: number) => void;
|
||||
/** Display name per part (see partDisplayName); defaults to the kind label. */
|
||||
partName?: (id: string) => string;
|
||||
}
|
||||
|
||||
interface PartShapeProps {
|
||||
part: PartBox;
|
||||
dashed?: boolean;
|
||||
name: string;
|
||||
hovered: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
onHover: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function PartShape({ part, dashed, name, hovered, onSelect, onHover }: PartShapeProps) {
|
||||
const [cx, cy, cz] = part.center;
|
||||
const [w, h, d] = part.size;
|
||||
const [x0, x1, y0, y1, z0, z1] = [cx - w / 2, cx + w / 2, cy - h / 2, cy + h / 2, cz - d / 2, cz + d / 2];
|
||||
const faces = [
|
||||
polygon([[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]]),
|
||||
polygon([[x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]]),
|
||||
polygon([[x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0]]),
|
||||
];
|
||||
const interactive = onSelect
|
||||
? {
|
||||
role: "button" as const,
|
||||
tabIndex: -1,
|
||||
onClick: () => onSelect(part.id),
|
||||
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(part.id)),
|
||||
onPointerEnter: () => onHover(part.id),
|
||||
onPointerLeave: () => onHover(null),
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<g
|
||||
data-part-id={part.id}
|
||||
data-hovered={hovered ? "true" : undefined}
|
||||
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
|
||||
{...interactive}
|
||||
>
|
||||
<title>{name}</title>
|
||||
{faces.map((d_, index) => (
|
||||
<path
|
||||
key={index}
|
||||
d={d_}
|
||||
className={`${edge} fill-holo-fill ${dashed ? "[stroke-dasharray:4_3]" : ""}`}
|
||||
strokeWidth={hovered ? 2.5 : 1.5}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameShapeProps {
|
||||
frame: FrameBox;
|
||||
width: number;
|
||||
patternPrefix: string;
|
||||
hovered: boolean;
|
||||
onSelect?: (boxId: string, slot: number) => void;
|
||||
onHover: (frame: { boxId: string; slot: number } | null) => void;
|
||||
}
|
||||
|
||||
function FrameShape({ frame, width, patternPrefix, hovered, onSelect, onHover }: FrameShapeProps) {
|
||||
const [cx, cy, cz] = frame.center;
|
||||
const [, h, d] = frame.size;
|
||||
const [x, y] = projectPoint([cx - width / 2, cy + h / 2, cz - d / 2]);
|
||||
const [x2, y2] = projectPoint([cx + width / 2, cy - h / 2, cz - d / 2]);
|
||||
const title = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
|
||||
const ref = { boxId: frame.boxId, slot: frame.slot };
|
||||
const interactive = onSelect
|
||||
? {
|
||||
role: "button" as const,
|
||||
tabIndex: -1,
|
||||
onClick: () => onSelect(frame.boxId, frame.slot),
|
||||
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(frame.boxId, frame.slot)),
|
||||
onPointerEnter: () => onHover(ref),
|
||||
onPointerLeave: () => onHover(null),
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<g
|
||||
data-frame-box={frame.boxId}
|
||||
data-frame-slot={frame.slot}
|
||||
data-frame-type={frame.type}
|
||||
data-hovered={hovered ? "true" : undefined}
|
||||
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
|
||||
{...interactive}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={x2 - x}
|
||||
height={y2 - y}
|
||||
fill={frame.type === "NONE" ? "none" : `url(#${patternPrefix}-${frame.type})`}
|
||||
className={`${edge} ${frame.type === "NONE" ? "[stroke-dasharray:2_3]" : ""}`}
|
||||
strokeWidth={hovered ? 2.5 : 1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function HiveSvg({ layout, onSelectPart, onSelectFrame, partName }: HiveSvgProps) {
|
||||
const view = useHiveView();
|
||||
const patternPrefix = `hive${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
const nameOf = (part: PartBox): string => partName?.(part.id) ?? PART_KIND_LABELS[part.kind];
|
||||
const frames = view.openBoxId ? layout.frames.filter((frame) => frame.boxId === view.openBoxId) : [];
|
||||
const hoveredFrame = view.hoveredFrame;
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={summaryOf(layout)}
|
||||
viewBox={viewBoxOf(layout)}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="block h-full w-full bg-holo-bg text-holo-edge forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] print:bg-transparent print:text-black"
|
||||
>
|
||||
{frames.length > 0 ? (
|
||||
<defs>
|
||||
{PATTERN_TYPES.map((type) => (
|
||||
<pattern
|
||||
key={type}
|
||||
id={`${patternPrefix}-${type}`}
|
||||
width={PATTERN_TILE}
|
||||
height={PATTERN_TILE}
|
||||
patternUnits="userSpaceOnUse"
|
||||
className="fill-none stroke-current"
|
||||
strokeWidth={0.02}
|
||||
>
|
||||
{PATTERNS[type]}
|
||||
</pattern>
|
||||
))}
|
||||
</defs>
|
||||
) : null}
|
||||
{layout.parts.map((part) => (
|
||||
<PartShape
|
||||
key={part.id}
|
||||
part={part}
|
||||
name={nameOf(part)}
|
||||
hovered={view.hoveredId === part.id}
|
||||
onSelect={onSelectPart}
|
||||
onHover={view.setHovered}
|
||||
/>
|
||||
))}
|
||||
{layout.inserts.map((insert) => (
|
||||
<PartShape
|
||||
key={insert.id}
|
||||
part={insert}
|
||||
dashed
|
||||
name={nameOf(insert)}
|
||||
hovered={view.hoveredId === insert.id}
|
||||
onSelect={onSelectPart}
|
||||
onHover={view.setHovered}
|
||||
/>
|
||||
))}
|
||||
{frames.map((frame) => (
|
||||
<FrameShape
|
||||
key={`${frame.boxId}-${frame.slot}`}
|
||||
frame={frame}
|
||||
width={framePitch(frame, frames) * FRAME_FILL_RATIO}
|
||||
patternPrefix={patternPrefix}
|
||||
hovered={hoveredFrame?.boxId === frame.boxId && hoveredFrame.slot === frame.slot}
|
||||
onSelect={onSelectFrame}
|
||||
onHover={view.setHoveredFrame}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { HiveLayout } from "@/lib/hive/layout";
|
||||
import type { HiveCatalog } from "@/lib/hive/types";
|
||||
import { HiveSvg } from "./hive-svg";
|
||||
|
||||
export interface HiveVisualSlotProps {
|
||||
layout: HiveLayout;
|
||||
catalog: HiveCatalog;
|
||||
partName: (id: string) => string;
|
||||
onSelectPart: (id: string) => void;
|
||||
onSelectFrame: (boxId: string, slot: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of the hive. L2 shows the SVG; the 3D scene (with the SVG as
|
||||
* fallback) replaces this body without changing the props.
|
||||
*/
|
||||
export function HiveVisualSlot({ layout, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
|
||||
return <HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRef, useState } from "react";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { PartSheet } from "./part-sheet";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
const baseState = (): HiveState => ({
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 1, fondantKg: 0 } }),
|
||||
part("l", "inner-lid", { state: { feedHole: "OPEN" } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
});
|
||||
|
||||
interface HarnessProps {
|
||||
partId: string;
|
||||
initialState?: HiveState;
|
||||
submit?: (ops: HiveOp[]) => Promise<unknown>;
|
||||
startWithEvaporator?: boolean;
|
||||
}
|
||||
|
||||
/** Applies submitted ops to its own state, like the optimistic cache does. */
|
||||
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
|
||||
const [state, setState] = useState(initialState);
|
||||
const [open, setOpen] = useState(true);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
const [store] = useState(createHiveViewStore);
|
||||
const send = async (ops: HiveOp[]) => {
|
||||
await submit?.(ops);
|
||||
const result = applyHiveOps(state, ops, catalog);
|
||||
if (result.ok) setState(result.state);
|
||||
};
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => {}}
|
||||
/>
|
||||
<PartSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
partId={partId}
|
||||
submit={send}
|
||||
outlineRef={outlineRef}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
/>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole("button", { name: "Speichern" });
|
||||
const dialog = () => screen.getByRole("dialog");
|
||||
|
||||
describe("PartSheet", () => {
|
||||
it("is titled with the part's display name", () => {
|
||||
render(<Harness partId="fe" />);
|
||||
expect(screen.getByRole("dialog", { name: "Futterzarge" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one control per state field", () => {
|
||||
render(<Harness partId="fe" />);
|
||||
expect(within(dialog()).getByLabelText("Flüssigfutter")).toHaveValue("1");
|
||||
expect(within(dialog()).getByLabelText("Futterteig")).toHaveValue("0");
|
||||
expect(within(dialog()).getAllByRole("textbox")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a choice field as a radio group", () => {
|
||||
render(<Harness partId="l" />);
|
||||
expect(screen.getByRole("radiogroup", { name: "Futterloch" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "offen" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("keeps Speichern disabled until something changed", () => {
|
||||
render(<Harness partId="fe" />);
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves only the changed level as setState", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="fe" submit={submit} />);
|
||||
const field = within(dialog()).getByLabelText("Flüssigfutter");
|
||||
await user.clear(field);
|
||||
await user.type(field, "3");
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }]);
|
||||
});
|
||||
|
||||
it("takes a German decimal comma", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="fe" submit={submit} />);
|
||||
const field = within(dialog()).getByLabelText("Flüssigfutter");
|
||||
await user.clear(field);
|
||||
await user.type(field, "2,5");
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "fe", key: "syrupLiters", value: 2.5 }]);
|
||||
});
|
||||
|
||||
it("blocks saving a level off the step grid and says so", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="fe" />);
|
||||
const field = within(dialog()).getByLabelText("Flüssigfutter");
|
||||
await user.clear(field);
|
||||
await user.type(field, "2,3");
|
||||
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("blocks saving an empty level", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="fe" />);
|
||||
await user.clear(within(dialog()).getByLabelText("Flüssigfutter"));
|
||||
expect(screen.getByText("Bitte einen Wert angeben.")).toBeInTheDocument();
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps the level at its maximum when more is typed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="fe" />);
|
||||
const field = within(dialog()).getByLabelText("Flüssigfutter");
|
||||
await user.clear(field);
|
||||
await user.type(field, "9");
|
||||
expect(field).toHaveValue("3");
|
||||
expect(save()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("saves a chosen option of a choice field", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="l" submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "zu" }));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "l", key: "feedHole", value: "CLOSED" }]);
|
||||
});
|
||||
|
||||
it("shows an error inline and stays open when saving fails", async () => {
|
||||
const submit = vi.fn().mockRejectedValue(new ApiError(500, "kaputt", null));
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="l" submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "zu" }));
|
||||
await user.click(save());
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
|
||||
expect(dialog()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on a conflict so the conflict sheet can take over", async () => {
|
||||
const submit = vi.fn().mockRejectedValue(
|
||||
new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "x", current: {} }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="l" submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "zu" }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("returns focus to the edited part, not to the body", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="l" />);
|
||||
await user.click(screen.getByRole("radio", { name: "zu" }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus());
|
||||
expect(document.body).not.toHaveFocus();
|
||||
});
|
||||
|
||||
describe("floor", () => {
|
||||
it("offers Bausperre drin / draußen and inserts the active floor insert type", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="f" submit={submit} />);
|
||||
expect(screen.getByRole("radiogroup", { name: "Bausperre" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "draußen" })).toBeChecked();
|
||||
await user.click(screen.getByRole("radio", { name: "drin" }));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{
|
||||
op: "addInsert",
|
||||
hostId: "f",
|
||||
part: { id: expect.any(String), partTypeId: "floor-insert", state: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes an existing Bausperre", async () => {
|
||||
const withInsert = baseState();
|
||||
withInsert.stack[0] = part("f", "floor", { inserts: [part("fi", "floor-insert")] });
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="f" initialState={withInsert} submit={submit} />);
|
||||
expect(screen.getByRole("radio", { name: "drin" })).toBeChecked();
|
||||
await user.click(screen.getByRole("radio", { name: "draußen" }));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "fi" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("box", () => {
|
||||
it("inserts an evaporator with the chosen acid level", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" submit={submit} />);
|
||||
expect(screen.queryByLabelText("Säure")).toBeNull();
|
||||
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
|
||||
const acid = within(dialog()).getByLabelText("Säure");
|
||||
await user.clear(acid);
|
||||
await user.type(acid, "250");
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{
|
||||
op: "addInsert",
|
||||
hostId: "b",
|
||||
part: { id: expect.any(String), partTypeId: "evaporator", state: { acidMl: 250 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks saving an evaporator whose acid is off the step grid", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" />);
|
||||
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
|
||||
const acid = within(dialog()).getByLabelText("Säure");
|
||||
await user.clear(acid);
|
||||
await user.type(acid, "255");
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("preselects the evaporator when opened from the outline action", () => {
|
||||
render(<Harness partId="b" startWithEvaporator />);
|
||||
expect(screen.getByRole("radio", { name: "Verdunster einsetzen" })).toBeChecked();
|
||||
expect(within(dialog()).getByLabelText("Säure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no insert when the box already has an evaporator", () => {
|
||||
const withEvaporator = baseState();
|
||||
withEvaporator.stack[1] = part("b", "box", {
|
||||
frames: frames(10),
|
||||
inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })],
|
||||
});
|
||||
render(<Harness partId="b" initialState={withEvaporator} />);
|
||||
expect(screen.queryByRole("radio", { name: "Verdunster einsetzen" })).toBeNull();
|
||||
});
|
||||
|
||||
it("moves focus to the new evaporator item after inserting", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" />);
|
||||
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
|
||||
const acid = within(dialog()).getByLabelText("Säure");
|
||||
await user.clear(acid);
|
||||
await user.type(acid, "100");
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toHaveFocus());
|
||||
expect(document.body).not.toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaporator", () => {
|
||||
const withEvaporator = (): HiveState => {
|
||||
const s = baseState();
|
||||
s.stack[1] = part("b", "box", {
|
||||
frames: frames(10),
|
||||
inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })],
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
it("edits the acid level", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
|
||||
const acid = within(dialog()).getByLabelText("Säure");
|
||||
await user.clear(acid);
|
||||
await user.type(acid, "200");
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ev", key: "acidMl", value: 200 }]);
|
||||
});
|
||||
|
||||
it("asks before removing, then removes and focuses the box", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
|
||||
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ev" }]);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
|
||||
expect(document.body).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("moves focus into the confirmation, labels it as a group and returns focus on cancel", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ev" initialState={withEvaporator()} submit={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
|
||||
const group = screen.getByRole("group", { name: "Verdunster wirklich entfernen?" });
|
||||
expect(within(group).getByText("Verdunster wirklich entfernen?")).toHaveFocus();
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps the evaporator when the confirmation is cancelled", async () => {
|
||||
const submit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
|
||||
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import {
|
||||
areDraftFieldsValid,
|
||||
draftOf,
|
||||
draftValue,
|
||||
isFieldChanged,
|
||||
StateFieldControl,
|
||||
} from "./state-field-control";
|
||||
import type { FieldDraft } from "./state-field-control";
|
||||
import { useSheetSubmit } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const INSERT_OPTIONS = [
|
||||
{ value: true, label: "drin" },
|
||||
{ value: false, label: "draußen" },
|
||||
];
|
||||
const EVAPORATOR_OPTIONS = [
|
||||
{ value: false, label: "Kein Verdunster" },
|
||||
{ value: true, label: "Verdunster einsetzen" },
|
||||
];
|
||||
|
||||
export interface PartSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
partId: string;
|
||||
/** Sends the ops (source CARD); rejects on failure. */
|
||||
submit: SubmitOps;
|
||||
/** On close, focus returns to the edited part's item in the outline. */
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
|
||||
startWithEvaporator?: boolean;
|
||||
}
|
||||
|
||||
interface Located {
|
||||
part: HivePart;
|
||||
/** The box or floor an insert sits in. */
|
||||
host: HivePart | null;
|
||||
}
|
||||
|
||||
function locate(state: HiveState, partId: string): Located | null {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === partId) return { part, host: null };
|
||||
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
|
||||
if (insert) return { part: insert, host: part };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function activeTypeOf(catalog: HiveCatalog, kind: HivePartKind): CatalogPartType | undefined {
|
||||
return [...catalog.partTypes.values()].find((type) => type.kind === kind && type.active);
|
||||
}
|
||||
|
||||
function insertOfKind(part: HivePart, catalog: HiveCatalog, kind: HivePartKind): HivePart | undefined {
|
||||
return (part.inserts ?? []).find((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === kind);
|
||||
}
|
||||
|
||||
function newPart(id: string, type: CatalogPartType, draft: FieldDraft): HivePart {
|
||||
const state: Record<string, number | string> = {};
|
||||
for (const field of type.stateSchema) state[field.key] = draftValue(field, draft[field.key] ?? "");
|
||||
return { id, partTypeId: type.id, state };
|
||||
}
|
||||
|
||||
interface PartFormProps {
|
||||
located: Located;
|
||||
type: CatalogPartType;
|
||||
catalog: HiveCatalog;
|
||||
startWithEvaporator: boolean;
|
||||
submit: SubmitOps;
|
||||
/** `focusId` is the part whose item should get focus once the sheet is closed. */
|
||||
onDone: (focusId: string) => void;
|
||||
}
|
||||
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
|
||||
const { part, host } = located;
|
||||
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
|
||||
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
|
||||
const evaporatorType =
|
||||
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
|
||||
|
||||
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
|
||||
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
|
||||
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
|
||||
const [evaporatorDraft, setEvaporatorDraft] = useState<FieldDraft>(() =>
|
||||
evaporatorType ? draftOf(evaporatorType, {}) : {},
|
||||
);
|
||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
||||
const questionRef = useRef<HTMLParagraphElement>(null);
|
||||
const removeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wasConfirming = useRef(false);
|
||||
|
||||
// Keyboard and screen reader users follow the confirmation, and get back to the button on cancel.
|
||||
useEffect(() => {
|
||||
if (confirmingRemoval) questionRef.current?.focus();
|
||||
else if (wasConfirming.current) removeButtonRef.current?.focus();
|
||||
wasConfirming.current = confirmingRemoval;
|
||||
}, [confirmingRemoval]);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
// Fixed per form, so a retry after an error sends the same ids.
|
||||
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
|
||||
|
||||
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
|
||||
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
|
||||
? [{ op: "setState" as const, id: part.id, key: field.key, value: draftValue(field, draft[field.key]) }]
|
||||
: [],
|
||||
);
|
||||
const ops: HiveOp[] = [...changedStateOps];
|
||||
if (floorInsertType && wantsFloorInsert && !currentFloorInsert) {
|
||||
ops.push({ op: "addInsert", hostId: part.id, part: newPart(newIds.floorInsert, floorInsertType, {}) });
|
||||
} else if (currentFloorInsert && !wantsFloorInsert) {
|
||||
ops.push({ op: "removePart", id: currentFloorInsert.id });
|
||||
}
|
||||
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
|
||||
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
|
||||
|
||||
const valid =
|
||||
areDraftFieldsValid(type, draft) &&
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
|
||||
|
||||
async function save() {
|
||||
// After inserting an evaporator, focus goes to the new item.
|
||||
const outcome = await send(ops);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
// An evaporator is an insert: focus goes to its box afterwards.
|
||||
const outcome = await send([{ op: "removePart", id: part.id }]);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-4 pb-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (ops.length > 0 && valid && !pending) void save();
|
||||
}}
|
||||
>
|
||||
{type.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${part.id}`}
|
||||
value={draft[field.key] ?? ""}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, [field.key]: value }))}
|
||||
/>
|
||||
))}
|
||||
{floorInsertType || currentFloorInsert ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`part-${part.id}-insert`} className="text-sm font-medium">
|
||||
Bausperre
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={INSERT_OPTIONS}
|
||||
value={wantsFloorInsert}
|
||||
onChange={setWantsFloorInsert}
|
||||
labelledBy={`part-${part.id}-insert`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{evaporatorType ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span id={`part-${part.id}-evaporator`} className="text-sm font-medium">
|
||||
Verdunster
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={EVAPORATOR_OPTIONS}
|
||||
value={wantsEvaporator}
|
||||
onChange={setWantsEvaporator}
|
||||
labelledBy={`part-${part.id}-evaporator`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
{wantsEvaporator
|
||||
? evaporatorType.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${part.id}-new-evaporator`}
|
||||
value={evaporatorDraft[field.key] ?? ""}
|
||||
onChange={(value) => setEvaporatorDraft((current) => ({ ...current, [field.key]: value }))}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
{type.kind === "EVAPORATOR" ? (
|
||||
confirmingRemoval ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={`part-${part.id}-confirm`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
|
||||
>
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
|
||||
Verdunster wirklich entfernen?
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
|
||||
Ja, entfernen
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => setConfirmingRemoval(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
ref={removeButtonRef}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
onClick={() => setConfirmingRemoval(true)}
|
||||
>
|
||||
Verdunster entfernen
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</SheetFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit one part: a control per state field, the floor's Bausperre, a box's
|
||||
* evaporator (spec 9.4). Everything changed goes out as one batch of ops.
|
||||
*/
|
||||
export function PartSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
state,
|
||||
catalog,
|
||||
partId,
|
||||
submit,
|
||||
outlineRef,
|
||||
startWithEvaporator = false,
|
||||
}: PartSheetProps) {
|
||||
const located = locate(state, partId);
|
||||
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
|
||||
// Set by a save that changes what should get focus (a new evaporator, the box of a removed one).
|
||||
const focusAfterSave = useRef<string | null>(null);
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={partDisplayName(state, catalog, partId)}
|
||||
finalFocus={() => {
|
||||
outlineRef.current?.focusPart(focusAfterSave.current ?? partId);
|
||||
focusAfterSave.current = null;
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{located && type ? (
|
||||
<PartForm
|
||||
key={partId}
|
||||
located={located}
|
||||
type={type}
|
||||
catalog={catalog}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
submit={submit}
|
||||
onDone={(id) => {
|
||||
focusAfterSave.current = id;
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
|
||||
Dieses Teil gibt es nicht mehr.
|
||||
</p>
|
||||
)}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { LEVEL_TOLERANCE } from "@/lib/hive/types";
|
||||
import type { CatalogPartType, ChoiceField, LevelField, StateField } from "@/lib/hive/types";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
/** Digits after the decimal point of a step: 0.5 -> 1, 10 -> 0. */
|
||||
export function decimalsOf(step: number): number {
|
||||
const text = String(step);
|
||||
const dot = text.indexOf(".");
|
||||
return dot < 0 ? 0 : text.length - dot - 1;
|
||||
}
|
||||
|
||||
/** Draft text per state field key ("2.5" for levels, the option for choices). */
|
||||
export type FieldDraft = Record<string, string>;
|
||||
|
||||
export function draftOf(type: CatalogPartType, state: Record<string, number | string>): FieldDraft {
|
||||
const draft: FieldDraft = {};
|
||||
for (const field of type.stateSchema) {
|
||||
const value = state[field.key];
|
||||
draft[field.key] = value === undefined ? defaultText(field) : String(value);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function defaultText(field: StateField): string {
|
||||
return field.kind === "level" ? "0" : (field.options[0] ?? "");
|
||||
}
|
||||
|
||||
/** Empty, out of 0..max or off the step grid: the value must not be saved. */
|
||||
export function isLevelValid(raw: string, field: LevelField): boolean {
|
||||
return levelProblem(raw, field) === null && !isOffGrid(raw, field);
|
||||
}
|
||||
|
||||
function isOffGrid(raw: string, field: LevelField): boolean {
|
||||
const position = Number(raw) / field.step;
|
||||
return Math.abs(position - Math.round(position)) > LEVEL_TOLERANCE;
|
||||
}
|
||||
|
||||
/** Message for an empty or out-of-range level; the stepper words the grid error itself. */
|
||||
export function levelProblem(raw: string, field: LevelField): string | null {
|
||||
const value = raw === "" ? Number.NaN : Number(raw);
|
||||
if (Number.isNaN(value)) return "Bitte einen Wert angeben.";
|
||||
if (value < 0 || value > field.max) return `Nur Werte von 0 bis ${numberFormat.format(field.max)}.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function areDraftFieldsValid(type: CatalogPartType, draft: FieldDraft): boolean {
|
||||
return type.stateSchema.every((field) => field.kind === "choice" || isLevelValid(draft[field.key] ?? "", field));
|
||||
}
|
||||
|
||||
/** The value as the API takes it: a number for levels, the option for choices. */
|
||||
export function draftValue(field: StateField, raw: string): number | string {
|
||||
return field.kind === "level" ? Number(raw) : raw;
|
||||
}
|
||||
|
||||
export function isFieldChanged(field: StateField, raw: string, current: number | string | undefined): boolean {
|
||||
if (field.kind === "choice") return raw !== String(current ?? defaultText(field));
|
||||
return Math.abs(Number(raw) - Number(current ?? 0)) > LEVEL_TOLERANCE;
|
||||
}
|
||||
|
||||
interface StateFieldControlProps {
|
||||
field: StateField;
|
||||
idPrefix: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function LevelControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: LevelField }) {
|
||||
const id = `${idPrefix}-${field.key}`;
|
||||
const problem = levelProblem(value, field);
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{field.label}
|
||||
</label>
|
||||
<NumberStepper
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={0}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
decimals={decimalsOf(field.step)}
|
||||
unitLabel={field.unit}
|
||||
decrementLabel={`${field.label} verringern`}
|
||||
incrementLabel={`${field.label} erhöhen`}
|
||||
invalid={problem !== null}
|
||||
describedBy={problem ? `${id}-problem` : undefined}
|
||||
/>
|
||||
{problem ? (
|
||||
<p id={`${id}-problem`} className="text-xs text-destructive">
|
||||
{problem}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: ChoiceField }) {
|
||||
const id = `${idPrefix}-${field.key}`;
|
||||
const options = field.options.map((option, index) => ({
|
||||
value: option,
|
||||
label: field.optionLabels[index] ?? option,
|
||||
}));
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`${id}-label`} className="text-sm font-medium">
|
||||
{field.label}
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
labelledBy={`${id}-label`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One control for one `stateSchema` field: a stepper for levels, a choice group for choices. */
|
||||
export function StateFieldControl(props: StateFieldControlProps) {
|
||||
const { field } = props;
|
||||
return field.kind === "level" ? (
|
||||
<LevelControl {...props} field={field} />
|
||||
) : (
|
||||
<ChoiceControl {...props} field={field} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type {
|
||||
HiveChangeListItem,
|
||||
HiveSetupView,
|
||||
HiveState,
|
||||
HiveSystemDto,
|
||||
} from "@/lib/hive/types";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
|
||||
// Shared by the hive card, init and history specs.
|
||||
|
||||
export const SYSTEM_ID = "sys";
|
||||
|
||||
export function systemDto(): HiveSystemDto {
|
||||
const catalog = inlineCatalog();
|
||||
return {
|
||||
id: SYSTEM_ID,
|
||||
name: "Zander nach Dr. Liebig",
|
||||
frameWidthMm: 477,
|
||||
frameHeightMm: 220,
|
||||
footprintLMm: 525,
|
||||
footprintWMm: 430,
|
||||
template: {},
|
||||
active: true,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
partTypes: [...catalog.partTypes.values()].map((row) => ({
|
||||
...row,
|
||||
systemId: SYSTEM_ID,
|
||||
articleRef: null,
|
||||
boxDepth: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Two boxes with 3 brood frames each, two feeders with 5 l together, a floor insert. */
|
||||
export function sampleState(): HiveState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", { frames: frames(10).map((frame) => (frame.slot <= 3 ? { ...frame, type: "BROOD" as const } : frame)) }),
|
||||
part("b2", "box", { frames: frames(10).map((frame) => (frame.slot <= 3 ? { ...frame, type: "BROOD" as const } : frame)) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 0 } }),
|
||||
part("fe2", "feeder", { state: { syrupLiters: 2, fondantKg: 0 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function setupView(state: HiveState = sampleState(), colonyId = "colony-1", version = 3): HiveSetupView {
|
||||
return {
|
||||
colonyId,
|
||||
systemId: SYSTEM_ID,
|
||||
version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, inlineCatalog()),
|
||||
updatedAt: "2026-10-12T10:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
export function colonyFixture(extra: Partial<Colony> = {}): Colony {
|
||||
return {
|
||||
id: "colony-1",
|
||||
code: "V-01",
|
||||
apiaryId: "apiary-1",
|
||||
hiveType: null,
|
||||
originText: null,
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
notes: null,
|
||||
currentQueenId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
deactivatedAt: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function historyItem(extra: Partial<HiveChangeListItem> = {}): HiveChangeListItem {
|
||||
return {
|
||||
id: "c1",
|
||||
version: 3,
|
||||
occurredOn: "2026-10-12",
|
||||
source: "INSPECTION",
|
||||
inspectionId: "i1",
|
||||
userId: "u1",
|
||||
userName: "Nick",
|
||||
ops: [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }],
|
||||
note: null,
|
||||
createdAt: "2026-10-12T12:00:00.000Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { hiveKeys, postHiveChange } from "@/lib/hive/api";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
|
||||
import { fetchColonyTreatments } from "@/lib/treatments/api";
|
||||
import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
vi.mock("sonner", () => ({ toast: vi.fn() }));
|
||||
vi.mock("@/lib/treatments/api", () => ({ fetchColonyTreatments: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
postHiveChange: vi.fn(),
|
||||
}));
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const COLONY = "colony-1";
|
||||
|
||||
const state = (feederLevel = 2): HiveState => ({
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: feederLevel, fondantKg: 0 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
});
|
||||
|
||||
const view = (s: HiveState, version = 1): HiveSetupView => ({
|
||||
colonyId: COLONY,
|
||||
systemId: "sys",
|
||||
version,
|
||||
state: s,
|
||||
facts: deriveHiveFacts(s, catalog),
|
||||
updatedAt: "2026-10-15T10:00:00.000Z",
|
||||
});
|
||||
|
||||
const refill: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
|
||||
|
||||
function conflictError(current: HiveState, reason = "MISSING_ID: no part with id x"): ApiError {
|
||||
return new ApiError(409, "Konflikt", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason,
|
||||
current: { version: 5, state: current },
|
||||
});
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(state()));
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(() => useHiveMutation(COLONY, catalog), { wrapper });
|
||||
const cached = () => client.getQueryData<HiveSetupView>(hiveKeys.detail(COLONY));
|
||||
return { client, hook, cached };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(postHiveChange).mockReset();
|
||||
vi.mocked(toast).mockReset();
|
||||
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]);
|
||||
push.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useHiveMutation", () => {
|
||||
it("shows the result in the cache before the server answers, then stores the answer", async () => {
|
||||
let resolve: (v: HiveSetupView) => void = () => undefined;
|
||||
vi.mocked(postHiveChange).mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { hook, cached } = setup();
|
||||
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
await waitFor(() => expect(cached()?.state.stack[2].state.syrupLiters).toBe(3));
|
||||
expect(cached()?.version).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
resolve(view(state(3), 2));
|
||||
await pending;
|
||||
});
|
||||
expect(cached()?.version).toBe(2);
|
||||
expect(vi.mocked(postHiveChange).mock.calls[0][1]).toEqual({
|
||||
occurredOn: "2026-10-15",
|
||||
source: "CARD",
|
||||
ops: refill,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults occurredOn to the Berlin day", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-15T22:30:00.000Z")); // already 16 Oct in Berlin
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD");
|
||||
});
|
||||
expect(vi.mocked(postHiveChange).mock.calls[0][1].occurredOn).toBe("2026-10-16");
|
||||
});
|
||||
|
||||
it("refetches instead of restoring a snapshot on a plain error, and rethrows", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(new ApiError(500, "kaputt", null));
|
||||
const { hook, client } = setup();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow("kaputt");
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.detail(COLONY) });
|
||||
expect(hook.result.current.conflict).toBeNull();
|
||||
});
|
||||
|
||||
it("restores the previous state when the request fails offline", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const { hook, cached } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a newer cache value instead of restoring the snapshot", async () => {
|
||||
let reject: (e: unknown) => void = () => undefined;
|
||||
vi.mocked(postHiveChange).mockReturnValue(new Promise((_, r) => (reject = r)));
|
||||
const { hook, cached, client } = setup();
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => expect(cached()?.state.stack[2].state.syrupLiters).toBe(3));
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(state(1), 9));
|
||||
await act(async () => {
|
||||
reject(new TypeError("Failed to fetch"));
|
||||
await pending;
|
||||
});
|
||||
expect(cached()?.version).toBe(9);
|
||||
});
|
||||
|
||||
it("uses a fresh key after a 4xx failure", async () => {
|
||||
vi.mocked(postHiveChange)
|
||||
.mockRejectedValueOnce(new ApiError(400, "falsch", null))
|
||||
.mockResolvedValueOnce(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls[1][2]).not.toBe(calls[0][2]);
|
||||
});
|
||||
|
||||
it("reuses the idempotency key when the same submit is pressed again after a network error", async () => {
|
||||
vi.mocked(postHiveChange)
|
||||
.mockRejectedValueOnce(new ApiError(503, "weg", null))
|
||||
.mockResolvedValueOnce(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls[1][2]).toBe(calls[0][2]);
|
||||
});
|
||||
|
||||
it("uses a fresh key for different ops and after success", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls[1][2]).not.toBe(calls[0][2]);
|
||||
});
|
||||
|
||||
it("rejects a second submit while one is running", async () => {
|
||||
let resolve: (v: HiveSetupView) => void = () => undefined;
|
||||
vi.mocked(postHiveChange).mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { hook } = setup();
|
||||
let first: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
first = hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow(HIVE_BUSY_MESSAGE);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve(view(state(3), 2));
|
||||
await first;
|
||||
});
|
||||
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a running detail refetch before the optimistic write", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook, client } = setup();
|
||||
const spy = vi.spyOn(client, "cancelQueries");
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.detail(COLONY) });
|
||||
});
|
||||
|
||||
it("invalidates the history after success", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook, client } = setup();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.history(COLONY) });
|
||||
});
|
||||
|
||||
it("suggests a feeding with the prefilled link, never booking one", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ action: { onClick: () => void } },
|
||||
];
|
||||
expect(message).toBe("1 l Flüssigfutter nachgefüllt. Als Fütterung eintragen?");
|
||||
options.action.onClick();
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
"/feedings/new?colonyId=colony-1&date=2026-10-15&amount=1&unit=l&feedType=SUGAR_WATER",
|
||||
);
|
||||
});
|
||||
|
||||
it("suggests a treatment when an evaporator with acid is inserted", async () => {
|
||||
const withEvaporator = state();
|
||||
withEvaporator.stack[1] = {
|
||||
...withEvaporator.stack[1],
|
||||
inserts: [part("ev", "evaporator", { state: { acidMl: 250 } })],
|
||||
};
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(withEvaporator, 2));
|
||||
const { hook } = setup();
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "addInsert", hostId: "b", part: part("ev", "evaporator", { state: { acidMl: 250 } }) },
|
||||
];
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(ops, "CARD", "2026-10-15");
|
||||
});
|
||||
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ action: { onClick: () => void } },
|
||||
];
|
||||
expect(message).toBe("Verdunster mit 250 ml eingesetzt. Als Behandlung eintragen?");
|
||||
options.action.onClick();
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
"/treatments/new?colonyId=colony-1&date=2026-10-15&amount=250&unit=ml",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent when an evaporator is removed and no treatment is open", async () => {
|
||||
const before = state();
|
||||
before.stack[1] = { ...before.stack[1], inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] };
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(), 2));
|
||||
vi.mocked(fetchColonyTreatments).mockResolvedValue([{ id: "t", endDate: "2026-10-01" }] as never);
|
||||
const { hook, client } = setup();
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(before));
|
||||
await act(async () => {
|
||||
await hook.result.current.submit([{ op: "removePart", id: "ev" }], "CARD", "2026-10-15");
|
||||
});
|
||||
await waitFor(() => expect(fetchColonyTreatments).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks whether to end the treatment when an evaporator is removed and one is open", async () => {
|
||||
const before = state();
|
||||
before.stack[1] = { ...before.stack[1], inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] };
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(), 2));
|
||||
const { hook, client } = setup();
|
||||
vi.mocked(fetchColonyTreatments).mockResolvedValue([{ id: "t", endDate: null }] as never);
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(before));
|
||||
await act(async () => {
|
||||
await hook.result.current.submit([{ op: "removePart", id: "ev" }], "CARD", "2026-10-15");
|
||||
});
|
||||
await waitFor(() => expect(toast).toHaveBeenCalled());
|
||||
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ action: { onClick: () => void } },
|
||||
];
|
||||
expect(message).toBe("Behandlung beenden?");
|
||||
options.action.onClick();
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1#behandlungen");
|
||||
});
|
||||
|
||||
describe("on 409 HIVE_CONFLICT", () => {
|
||||
const serverState = (): HiveState => {
|
||||
const s = state(1);
|
||||
return s;
|
||||
};
|
||||
|
||||
it("keeps own ops, the current state and a preview, and shows the server state", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(serverState()));
|
||||
const { hook, cached } = setup();
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
const conflict = hook.result.current.conflict;
|
||||
expect(conflict?.ops).toEqual(refill);
|
||||
expect(conflict?.current.version).toBe(5);
|
||||
expect(conflict?.preview).toMatchObject({ ok: true });
|
||||
if (conflict?.preview.ok) expect(conflict.preview.state.stack[2].state.syrupLiters).toBe(3);
|
||||
expect(cached()?.version).toBe(5);
|
||||
expect(cached()?.state.stack[2].state.syrupLiters).toBe(1);
|
||||
});
|
||||
|
||||
it("reports a failing preview with op index and reason", async () => {
|
||||
const gone = serverState();
|
||||
gone.stack = gone.stack.filter((p) => p.id !== "fe");
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(gone));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
const preview = hook.result.current.conflict?.preview;
|
||||
expect(preview?.ok).toBe(false);
|
||||
if (preview && !preview.ok) {
|
||||
expect(preview.opIndex).toBe(0);
|
||||
expect(preview.reason).toMatch(/MISSING_ID/);
|
||||
}
|
||||
});
|
||||
|
||||
it("retry resubmits the ops with a new idempotency key", async () => {
|
||||
vi.mocked(postHiveChange)
|
||||
.mockRejectedValueOnce(conflictError(serverState()))
|
||||
.mockResolvedValueOnce(view(state(3), 6));
|
||||
const { hook, cached } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.resolveConflict("retry");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1][1]).toEqual({ occurredOn: "2026-10-15", source: "CARD", ops: refill });
|
||||
expect(calls[1][2]).not.toBe(calls[0][2]);
|
||||
expect(hook.result.current.conflict).toBeNull();
|
||||
expect(cached()?.version).toBe(6);
|
||||
});
|
||||
|
||||
it("does not resubmit when the preview failed", async () => {
|
||||
const gone = serverState();
|
||||
gone.stack = gone.stack.filter((p) => p.id !== "fe");
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(gone));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.resolveConflict("retry");
|
||||
});
|
||||
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
|
||||
expect(hook.result.current.conflict).not.toBeNull();
|
||||
});
|
||||
|
||||
it("discard clears the conflict", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(serverState()));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.resolveConflict("discard");
|
||||
});
|
||||
await waitFor(() => expect(hook.result.current.conflict).toBeNull());
|
||||
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import { fetchColonyTreatments } from "@/lib/treatments/api";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type {
|
||||
ClientHiveChangeSource,
|
||||
HiveApplyResult,
|
||||
HiveCatalog,
|
||||
HiveOp,
|
||||
HiveSetupView,
|
||||
HiveState,
|
||||
} from "@/lib/hive/types";
|
||||
import { showHiveFollowUps } from "./follow-up-toast";
|
||||
|
||||
/** The server refused the ops against its newer state (409 HIVE_CONFLICT). */
|
||||
export interface HiveConflict {
|
||||
/** The state the server holds now. */
|
||||
current: { version: number; state: HiveState };
|
||||
/** The user's own ops. */
|
||||
ops: HiveOp[];
|
||||
/** The ops re-applied to `current.state`; a failure carries op index and reason. */
|
||||
preview: HiveApplyResult;
|
||||
}
|
||||
|
||||
export interface HiveMutation {
|
||||
submit(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string): Promise<HiveSetupView>;
|
||||
conflict: HiveConflict | null;
|
||||
/** "retry" sends the ops again against the current state (new key); "discard" drops them. */
|
||||
resolveConflict(action: "retry" | "discard"): Promise<void>;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
ops: HiveOp[];
|
||||
source: ClientHiveChangeSource;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
/** Thrown for a submit while another one of this hook is still running. */
|
||||
export const HIVE_BUSY_MESSAGE = "HIVE_BUSY";
|
||||
|
||||
/** Same ops, source and day: a manual re-press after a network error. */
|
||||
function signatureOf(attempt: Attempt): string {
|
||||
return JSON.stringify([attempt.source, attempt.occurredOn, attempt.ops]);
|
||||
}
|
||||
|
||||
/** No verdict from the server (network, timeout, 5xx): the change may or may not be saved. */
|
||||
function isAmbiguousFailure(error: unknown): boolean {
|
||||
return !(error instanceof ApiError) || error.status >= 500;
|
||||
}
|
||||
|
||||
function withState(view: HiveSetupView, state: HiveState, version: number, catalog: HiveCatalog): HiveSetupView {
|
||||
return { ...view, version, state, facts: deriveHiveFacts(state, catalog) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a change of the colony's hive. The cache shows the result at once
|
||||
* (optimistic); a failure refetches it. One submit at a time. An attempt keeps
|
||||
* its idempotency key across manual re-presses after a network error or 5xx;
|
||||
* a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only.
|
||||
*/
|
||||
export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [conflict, setConflict] = useState<HiveConflict | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const lastAttempt = useRef<Attempt | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
// Key of an attempt that failed without a verdict (network, 5xx): a re-press reuses it.
|
||||
const failedKey = useRef<{ signature: string; key: string } | null>(null);
|
||||
const queryKey = hiveKeys.detail(colonyId);
|
||||
|
||||
const hasOpenTreatment = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const treatments = await queryClient.fetchQuery({
|
||||
queryKey: ["colonies", colonyId, "treatments"],
|
||||
queryFn: () => fetchColonyTreatments(colonyId),
|
||||
});
|
||||
return treatments.some((treatment) => treatment.endDate == null);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [colonyId, queryClient]);
|
||||
|
||||
const suggestFollowUps = useCallback(
|
||||
async (attempt: Attempt, before: HiveState, after: HiveState): Promise<void> => {
|
||||
const { ops, occurredOn } = attempt;
|
||||
const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment() : false;
|
||||
showHiveFollowUps(ops, before, after, catalog, {
|
||||
colonyId,
|
||||
occurredOn,
|
||||
navigate: (href) => router.push(href),
|
||||
hasOpenTreatment: open,
|
||||
});
|
||||
},
|
||||
[catalog, colonyId, hasOpenTreatment, router],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (attempt: Attempt): Promise<HiveSetupView> => {
|
||||
if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE);
|
||||
inFlight.current = true;
|
||||
const { ops, source, occurredOn } = attempt;
|
||||
const signature = signatureOf(attempt);
|
||||
const key =
|
||||
failedKey.current?.signature === signature ? failedKey.current.key : newIdempotencyKey();
|
||||
setPendingCount((count) => count + 1);
|
||||
try {
|
||||
// A running refetch must not overwrite the optimistic state.
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<HiveSetupView>(queryKey);
|
||||
if (previous) {
|
||||
const applied = applyHiveOps(previous.state, ops, catalog);
|
||||
if (applied.ok) {
|
||||
queryClient.setQueryData(queryKey, withState(previous, applied.state, previous.version, catalog));
|
||||
}
|
||||
}
|
||||
let optimistic: HiveSetupView | undefined;
|
||||
if (previous) optimistic = queryClient.getQueryData<HiveSetupView>(queryKey);
|
||||
try {
|
||||
const view = await postHiveChange(colonyId, { occurredOn, source, ops }, key);
|
||||
queryClient.setQueryData(queryKey, view);
|
||||
setConflict(null);
|
||||
lastAttempt.current = null;
|
||||
failedKey.current = null;
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) });
|
||||
if (previous) {
|
||||
// Follow-ups are best effort: a failing suggestion must not fail the saved change.
|
||||
suggestFollowUps(attempt, previous.state, view.state).catch(() => {});
|
||||
}
|
||||
return view;
|
||||
} catch (error) {
|
||||
const body = hiveConflictOf(error);
|
||||
if (body) {
|
||||
failedKey.current = null;
|
||||
lastAttempt.current = attempt;
|
||||
if (previous) {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
withState(previous, body.current.state, body.current.version, catalog),
|
||||
);
|
||||
}
|
||||
setConflict({
|
||||
current: body.current,
|
||||
ops,
|
||||
preview: applyHiveOps(body.current.state, ops, catalog),
|
||||
});
|
||||
} else {
|
||||
failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null;
|
||||
// Restore only if nobody changed the cache since our optimistic write; then refetch.
|
||||
if (previous && optimistic && queryClient.getQueryData(queryKey) === optimistic) {
|
||||
queryClient.setQueryData(queryKey, previous);
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setPendingCount((count) => count - 1);
|
||||
}
|
||||
},
|
||||
[catalog, colonyId, queryClient, queryKey, suggestFollowUps],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string) =>
|
||||
run({ ops, source, occurredOn: occurredOn ?? berlinCalendarDay(new Date().toISOString()) }),
|
||||
[run],
|
||||
);
|
||||
|
||||
const resolveConflict = useCallback(
|
||||
async (action: "retry" | "discard"): Promise<void> => {
|
||||
const attempt = lastAttempt.current;
|
||||
if (action === "discard" || !attempt) {
|
||||
lastAttempt.current = null;
|
||||
setConflict(null);
|
||||
return;
|
||||
}
|
||||
if (conflict && !conflict.preview.ok) return;
|
||||
try {
|
||||
await run(attempt);
|
||||
} catch (error) {
|
||||
// A new conflict is already in `conflict`; anything else is for the caller.
|
||||
if (!hiveConflictOf(error)) throw error;
|
||||
}
|
||||
},
|
||||
[conflict, run],
|
||||
);
|
||||
|
||||
return { submit, conflict, resolveConflict, isPending: pendingCount > 0 };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { catalogFromSystem } from "@/lib/hive/facts";
|
||||
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
|
||||
import type { HiveCatalog, HiveSystemDto } from "@/lib/hive/types";
|
||||
|
||||
/** The shared systems query (catalog of every system); the card and the init flow read the same cache. */
|
||||
export function useHiveSystems() {
|
||||
return useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
|
||||
}
|
||||
|
||||
/** Catalog of one system, stable while the systems data stays the same. */
|
||||
export function useSystemCatalog(
|
||||
systems: HiveSystemDto[] | undefined,
|
||||
systemId: string | undefined,
|
||||
): HiveCatalog | null {
|
||||
return useMemo(() => {
|
||||
const system = systems?.find((candidate) => candidate.id === systemId);
|
||||
return system ? catalogFromSystem(system) : null;
|
||||
}, [systems, systemId]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
|
||||
describe("createHiveViewStore", () => {
|
||||
it("starts empty and keeps the same state object until something changes", () => {
|
||||
const store = createHiveViewStore();
|
||||
const first = store.getState();
|
||||
expect(first).toEqual({ hoveredId: null, openBoxId: null, hoveredFrame: null });
|
||||
store.setHovered(null);
|
||||
expect(store.getState()).toBe(first);
|
||||
store.setHovered("a");
|
||||
expect(store.getState()).not.toBe(first);
|
||||
expect(store.getState().hoveredId).toBe("a");
|
||||
});
|
||||
|
||||
it("notifies subscribers only on real changes and supports unsubscribe", () => {
|
||||
const store = createHiveViewStore();
|
||||
const listener = vi.fn();
|
||||
const off = store.subscribe(listener);
|
||||
store.setHovered("a");
|
||||
store.setHovered("a");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
off();
|
||||
store.setHovered("b");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opening a box clears the hovered frame", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 3 });
|
||||
store.openBox("b");
|
||||
expect(store.getState()).toMatchObject({ openBoxId: "b", hoveredFrame: null });
|
||||
});
|
||||
|
||||
it("openBox(null) clears the open box and the hovered frame", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b");
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
store.openBox(null);
|
||||
expect(store.getState()).toMatchObject({ openBoxId: null, hoveredFrame: null });
|
||||
});
|
||||
|
||||
it("treats an equal frame as no change", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
const before = store.getState();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
expect(store.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHiveView", () => {
|
||||
it("exposes state and setters from the provider and re-renders on change", () => {
|
||||
const store = createHiveViewStore();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<HiveViewProvider store={store}>{children}</HiveViewProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useHiveView(), { wrapper });
|
||||
expect(result.current.hoveredId).toBeNull();
|
||||
act(() => result.current.setHovered("x"));
|
||||
expect(result.current.hoveredId).toBe("x");
|
||||
act(() => result.current.openBox("b"));
|
||||
expect(result.current.openBoxId).toBe("b");
|
||||
});
|
||||
|
||||
it("throws outside a provider", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// View state shared by the outline, the SVG and the 3D scene. It lives outside
|
||||
// three.js so every surface sees the same hover and the same open box (spec 9.1).
|
||||
|
||||
export interface HoveredFrame {
|
||||
boxId: string;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
export interface HiveViewState {
|
||||
hoveredId: string | null;
|
||||
openBoxId: string | null;
|
||||
hoveredFrame: HoveredFrame | null;
|
||||
}
|
||||
|
||||
export interface HiveViewStore {
|
||||
getState(): HiveViewState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
setHovered(id: string | null): void;
|
||||
/** Opens a box (clears the hovered frame); null closes it and clears the hovered frame too. */
|
||||
openBox(id: string | null): void;
|
||||
setHoveredFrame(frame: HoveredFrame | null): void;
|
||||
}
|
||||
|
||||
export interface HiveView extends HiveViewState {
|
||||
setHovered(id: string | null): void;
|
||||
openBox(id: string | null): void;
|
||||
setHoveredFrame(frame: HoveredFrame | null): void;
|
||||
}
|
||||
|
||||
const sameFrame = (a: HoveredFrame | null, b: HoveredFrame | null): boolean =>
|
||||
a === b || (a !== null && b !== null && a.boxId === b.boxId && a.slot === b.slot);
|
||||
|
||||
export function createHiveViewStore(): HiveViewStore {
|
||||
let state: HiveViewState = { hoveredId: null, openBoxId: null, hoveredFrame: null };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// The state object only changes when a value really changes, so
|
||||
// useSyncExternalStore gets a stable snapshot.
|
||||
const update = (patch: Partial<HiveViewState>): void => {
|
||||
const next = { ...state, ...patch };
|
||||
if (
|
||||
next.hoveredId === state.hoveredId &&
|
||||
next.openBoxId === state.openBoxId &&
|
||||
sameFrame(next.hoveredFrame, state.hoveredFrame)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
setHovered: (id) => update({ hoveredId: id }),
|
||||
openBox: (id) => update({ openBoxId: id, hoveredFrame: null }),
|
||||
setHoveredFrame: (frame) => update({ hoveredFrame: frame }),
|
||||
};
|
||||
}
|
||||
|
||||
const HiveViewContext = createContext<HiveViewStore | null>(null);
|
||||
|
||||
export function HiveViewProvider({ store, children }: { store: HiveViewStore; children: ReactNode }) {
|
||||
return createElement(HiveViewContext.Provider, { value: store }, children);
|
||||
}
|
||||
|
||||
export function useHiveView(): HiveView {
|
||||
const store = useContext(HiveViewContext);
|
||||
if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider");
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
||||
return useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
setHovered: store.setHovered,
|
||||
openBox: store.openBox,
|
||||
setHoveredFrame: store.setHoveredFrame,
|
||||
}),
|
||||
[state, store],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { hiveConflictOf } from "@/lib/hive/api";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
|
||||
export const SAVE_FAILED_TEXT = "Speichern fehlgeschlagen. Bitte noch einmal versuchen.";
|
||||
|
||||
/** A refusal with a German sentence the sheet may show as it is (e.g. a draft that breaks a rule). */
|
||||
export class HiveDraftError extends Error {}
|
||||
|
||||
export type SubmitOps = (ops: HiveOp[]) => Promise<unknown>;
|
||||
|
||||
export type SubmitOutcome = "saved" | "conflict" | "failed";
|
||||
|
||||
/**
|
||||
* Sends ops for a sheet. On a conflict the caller closes the sheet (the
|
||||
* conflict sheet takes over, so sheets never stack); other failures stay inline.
|
||||
*/
|
||||
export function useSheetSubmit(submit: SubmitOps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const send = useCallback(
|
||||
async (ops: HiveOp[]): Promise<SubmitOutcome> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submit(ops);
|
||||
return "saved";
|
||||
} catch (failure) {
|
||||
if (hiveConflictOf(failure)) return "conflict";
|
||||
setError(failure instanceof HiveDraftError ? failure.message : SAVE_FAILED_TEXT);
|
||||
return "failed";
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
return { send, pending, error };
|
||||
}
|
||||
Reference in new issue
Block a user