From 8166e7c621944c8249dfdfa3da4be981340d601b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 09:11:43 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Beute-Editor=20und=20Durchsicht=20nach?= =?UTF-8?q?=20Browser-Pr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Umbauen: Fokus nach Rückgängig/Wiederholen am Ende auf den anderen Verlaufsknopf, nach Entfernen auf die Nachbarzeile (sonst Überschrift) - Umbauen: Hinweis „Beute wurde inzwischen geändert“ zuverlässig, der Stand wird bei Fokus/Sichtbarkeit und alle 30 s neu geladen, solange ein Entwurf besteht; Editor-Wurzel ist
- Verlauf: Futtertaschen-Füllstand als „Flüssigfutter 1,5 l“, Entfernen als „Futtertasche entfernt“; Gliederung ohne leere Werte („Boden · /“) - Durchsicht: Rückgängig der letzten Änderung behält den Schalter „Liste“ und setzt den Fokus auf die Überschrift der Änderungen Refs #21 #22 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../(app)/colonies/[id]/hive/page.spec.tsx | 45 ++++++++ .../src/app/(app)/colonies/[id]/hive/page.tsx | 5 +- .../(app)/inspections/new/hive-section.tsx | 12 ++- .../(app)/inspections/new/page-hive.spec.tsx | 27 ++++- .../components/hive/editor/editor-toolbar.tsx | 9 +- .../components/hive/editor/hive-editor.tsx | 102 ++++++++++++++++-- .../src/components/hive/hive-card.tsx | 18 +++- .../src/components/hive/hive-history.tsx | 19 ++-- .../src/components/hive/hive-outline.spec.tsx | 15 +++ .../src/components/hive/hive-outline.tsx | 8 +- .../src/lib/hive/describe.spec.ts | 15 ++- ApiCore-Frontend/src/lib/hive/describe.ts | 14 ++- 12 files changed, 253 insertions(+), 36 deletions(-) diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx index 331b3d7..7e87a93 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx @@ -243,6 +243,51 @@ describe("HiveEditorPage", () => { expect(fetchHive).toHaveBeenCalledTimes(1); }); + it("moves focus to the other history button when undo or redo runs out", async () => { + renderPage(); + await addPart("cover"); + + await userEvent.click(screen.getByRole("button", { name: "Rückgängig" })); + expect(screen.getByRole("button", { name: "Rückgängig" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Wiederholen" })).toHaveFocus(); + + await userEvent.click(screen.getByRole("button", { name: "Wiederholen" })); + expect(screen.getByRole("button", { name: "Wiederholen" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Rückgängig" })).toHaveFocus(); + }); + + it("moves focus to the next row after removing a part, else the previous one", async () => { + renderPage(); + const stack = await screen.findByRole("list", { name: "Aufbau" }); + + await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" })); + expect(document.activeElement).toHaveAttribute("data-part-id", "fe"); + + // The floor insert is the last row: focus falls back to the row above (the floor). + await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Bausperre" })); + expect(document.activeElement).toHaveAttribute("data-part-id", "f"); + }); + + it("refreshes the setup on window focus and hints when the server moved on", async () => { + renderPage(); + await addPart("cover"); + const other = sampleState(); + other.stack = other.stack.filter((p) => p.id !== "b2"); + vi.mocked(fetchHive).mockResolvedValue(setupView(other, "colony-1", 4)); + const calls = vi.mocked(fetchHive).mock.calls.length; + + act(() => void window.dispatchEvent(new Event("focus"))); + + expect(await screen.findByText(/Die Beute wurde inzwischen geändert\. Beim Speichern/)).toBeInTheDocument(); + expect(vi.mocked(fetchHive).mock.calls.length).toBeGreaterThan(calls); + }); + + it("is a main landmark", async () => { + renderPage(); + + expect(await screen.findByRole("main")).toBeInTheDocument(); + }); + it("announces undo and redo", async () => { renderPage(); await addPart("cover"); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx index 696a1cc..900879e 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx @@ -18,7 +18,9 @@ export default function HiveEditorPage() { const connectivity = useConnectivity(); const backHref = `/colonies/${encodeURIComponent(id)}`; const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(id) }); - const hive = useQuery({ queryKey: hiveKeys.detail(id), queryFn: () => fetchHive(id), + const hive = useQuery({ + queryKey: hiveKeys.detail(id), + queryFn: () => fetchHive(id), // A colony without a hive answers 404 for good; no point retrying. retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3, }); @@ -72,6 +74,7 @@ export default function HiveEditorPage() { setup={hive.data} catalog={catalog} canManageTemplates={hasPermission("hive-systems:manage")} + onRefresh={() => void hive.refetch()} /> ); } diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx index 444413b..724e69b 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx @@ -140,17 +140,19 @@ export function InspectionHiveSection({ catalog, errorText, }: InspectionHiveSectionProps) { - const sectionRef = useRef(null); + const headingRef = useRef(null); + const [outlineShown, setOutlineShown] = useState(false); const [announcement, setAnnouncement] = useState(""); return ( // A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
event.stopPropagation()} > +

+ Beute-Änderungen +

{announcement}

@@ -162,6 +164,8 @@ export function InspectionHiveSection({ mode={canEdit ? "inspection" : "view"} onOpsChange={canEdit ? onOpsChange : undefined} initialOps={canEdit ? ops : undefined} + initialShowOutline={outlineShown} + onShowOutlineChange={setOutlineShown} />
{canEdit && ops.length > 0 && base && catalog ? ( @@ -171,7 +175,7 @@ export function InspectionHiveSection({ catalog={catalog} onChange={onOpsReplace} onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)} - onEmptied={() => sectionRef.current?.focus()} + onEmptied={() => headingRef.current?.focus()} /> ) : null} {errorText ? ( diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx index bbb7a02..3c23e30 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx @@ -50,10 +50,16 @@ vi.mock("@/lib/hive/api", async (importOriginal) => ({ fetchHiveSystems: vi.fn(), })); -const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[] }[] })); +const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[]; initialShowOutline?: boolean }[] })); vi.mock("@/components/hive/hive-card", () => ({ - HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => { - card.renders.push({ mode: props.mode, initialOps: props.initialOps }); + HiveCard: (props: { + mode?: string; + initialOps?: unknown[]; + initialShowOutline?: boolean; + onOpsChange?: (ops: unknown[]) => void; + onShowOutlineChange?: (shown: boolean) => void; + }) => { + card.renders.push({ mode: props.mode, initialOps: props.initialOps, initialShowOutline: props.initialShowOutline }); const fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 }; const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }; const add = { @@ -73,6 +79,9 @@ vi.mock("@/components/hive/hive-card", () => ({ + @@ -217,7 +226,7 @@ describe("NewInspectionPage: section Beute", () => { await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ })); - expect(screen.getByTestId("hive-section")).toHaveFocus(); + expect(screen.getByRole("heading", { name: "Beute-Änderungen" })).toHaveFocus(); }); it("keeps focus on a row when identical ops renumber their keys", async () => { @@ -229,6 +238,16 @@ describe("NewInspectionPage: section Beute", () => { expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus(); }); + it("keeps the card's Liste toggle across the remount after Rückgängig", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + await userEvent.click(screen.getByRole("button", { name: "Test Liste" })); + + await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ })); + + expect(card.renders.at(-1)?.initialShowOutline).toBe(true); + }); + it("hides a refusal once the ops changed", async () => { vi.mocked(createInspection).mockRejectedValue( new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }), diff --git a/ApiCore-Frontend/src/components/hive/editor/editor-toolbar.tsx b/ApiCore-Frontend/src/components/hive/editor/editor-toolbar.tsx index 54c7200..38eff09 100644 --- a/ApiCore-Frontend/src/components/hive/editor/editor-toolbar.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/editor-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react"; import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass"; @@ -10,6 +10,8 @@ export interface EditorToolbarProps { title: string; canUndo: boolean; canRedo: boolean; + undoRef?: Ref; + redoRef?: Ref; onUndo: () => void; onRedo: () => void; date: string; @@ -26,19 +28,20 @@ export interface EditorToolbarProps { } /** Top bar of the editor: back, title, undo/redo, date and the three ways to finish. */ -export function EditorToolbar(props: EditorToolbarProps) { +export function EditorToolbar({ undoRef, redoRef, ...props }: EditorToolbarProps) { const { date, today, hasOps, saving } = props; const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today; return (
{props.back}

{props.title}

-
); } diff --git a/ApiCore-Frontend/src/components/hive/hive-card.tsx b/ApiCore-Frontend/src/components/hive/hive-card.tsx index 7ab5cf4..b53d508 100644 --- a/ApiCore-Frontend/src/components/hive/hive-card.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-card.tsx @@ -57,6 +57,9 @@ export interface HiveCardProps { * loaded hive; ignored in view mode. */ initialOps?: HiveOp[]; + /** Start with the outline list shown, and report its toggle, so a host that remounts the card can keep it. */ + initialShowOutline?: boolean; + onShowOutlineChange?: (shown: boolean) => void; } /** @@ -86,7 +89,7 @@ function PlainCardBody({ children }: { children: ReactNode }) { ); } -function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps }: HiveCardProps) { +function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps, initialShowOutline, onShowOutlineChange }: HiveCardProps) { const { hasPermission } = useAuth(); // Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10). const hive = useHiveSetup(colonyId); @@ -144,6 +147,8 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial mode={mode} onOpsChange={onOpsChange} initialOps={initialOps} + initialShowOutline={initialShowOutline} + onShowOutlineChange={onShowOutlineChange} /> ); } @@ -207,6 +212,8 @@ interface HiveCardBodyProps { mode: "view" | "inspection"; onOpsChange?: (ops: HiveOp[]) => void; initialOps?: HiveOp[]; + initialShowOutline?: boolean; + onShowOutlineChange?: (shown: boolean) => void; } /** The seed of the inspection draft: the given ops when they apply to the loaded hive, else none. */ @@ -215,7 +222,7 @@ function initialDraft(mode: "view" | "inspection", setup: HiveSetupView, catalog return applyHiveOps(setup.state, ops, catalog).ok ? ops : []; } -function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps }: HiveCardBodyProps) { +function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps, initialShowOutline = false, onShowOutlineChange }: HiveCardBodyProps) { const view = useHiveView(); const connectivity = useConnectivity(); const mutation = useHiveMutation(colonyId, catalog); @@ -236,7 +243,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in // eslint-disable-next-line react-hooks/exhaustive-deps }, [seedDropped]); const [past, setPast] = useState(null); - const [showOutline, setShowOutline] = useState(false); + const [showOutline, setShowOutline] = useState(initialShowOutline); const [historyOpen, setHistoryOpen] = useState(false); const [frameTarget, setFrameTarget] = useState(null); const [frameOpen, setFrameOpen] = useState(false); @@ -380,7 +387,10 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in aria-pressed={showOutline} aria-controls={outlineId} aria-label="Aufbau als Liste" - onClick={() => setShowOutline(!showOutline)} + onClick={() => { + setShowOutline(!showOutline); + onShowOutlineChange?.(!showOutline); + }} className={GLASS_BUTTON} > diff --git a/ApiCore-Frontend/src/components/hive/hive-history.tsx b/ApiCore-Frontend/src/components/hive/hive-history.tsx index cb0f426..e254e86 100644 --- a/ApiCore-Frontend/src/components/hive/hive-history.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-history.tsx @@ -5,9 +5,9 @@ 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 { describeHiveOps, partsAddedBy } from "@/lib/hive/describe"; import { shortGermanDay, sourceLabel } from "@/lib/hive/summary"; -import type { HiveCatalog, HiveChangeListItem, HiveOp, HiveState } from "@/lib/hive/types"; +import type { HiveCatalog, HiveChangeListItem, HiveOp, HivePart, HiveState } from "@/lib/hive/types"; const LOAD_FAILED_TEXT = "Stand konnte nicht geladen werden."; @@ -21,10 +21,14 @@ export function useHiveHistory(colonyId: string) { }); } -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); +function opsOf(item: HiveChangeListItem): HiveOp[] { + return Array.isArray(item.ops) ? (item.ops as HiveOp[]) : []; +} + +function textOf(item: HiveChangeListItem, state: HiveState, catalog: HiveCatalog, seen: HivePart[]): string { + // Items carry no state before; the current state names parts that still exist, + // the parts other changes added name the ones that were removed since. + const text = describeHiveOps(opsOf(item), state, catalog, seen); if (text) return text; return item.source === "INIT" ? "Beute angelegt" : "Änderung"; } @@ -44,6 +48,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis const [loadingId, setLoadingId] = useState(null); const [error, setError] = useState(null); const items = history.data?.pages.flatMap((page) => page.items) ?? []; + const seen = items.flatMap((item) => partsAddedBy(opsOf(item))); async function show(item: HiveChangeListItem) { setLoadingId(item.id); @@ -94,7 +99,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis {shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)} - {textOf(item, state, catalog)} + {textOf(item, state, catalog, seen)} {isLate ? ( nachgetragen am {shortGermanDay(enteredOn)} ) : null} diff --git a/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx index 6968d48..ed623ea 100644 --- a/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx @@ -60,6 +60,21 @@ describe("HiveOutline", () => { expect(items.findIndex((t) => t?.includes("Zarge 2"))).toBeLessThan(items.findIndex((t) => t?.includes("Zarge 1"))); }); + it("shows no separator or empty value for a part whose state is not set", () => { + const emptyCatalog = inlineCatalog(); + const floor = emptyCatalog.partTypes.get("floor")!; + emptyCatalog.partTypes.set("floor", { + ...floor, + stateSchema: [ + { key: "a", label: "A", kind: "choice", options: ["X"], optionLabels: ["x"] }, + { key: "b", label: "B", kind: "choice", options: ["Y"], optionLabels: ["y"] }, + ], + }); + setup({ catalog: emptyCatalog, state: { schemaVersion: 1, stack: [part("f", "floor"), part("r", "roof")] } }); + + expect(names().find((t) => t?.startsWith("Boden"))).toBe("Boden"); + }); + it("shows a short state hint per part", () => { setup(); const text = names().join("|"); diff --git a/ApiCore-Frontend/src/components/hive/hive-outline.tsx b/ApiCore-Frontend/src/components/hive/hive-outline.tsx index feb5da3..bc4860f 100644 --- a/ApiCore-Frontend/src/components/hive/hive-outline.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-outline.tsx @@ -49,9 +49,11 @@ function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] { 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(" / "); - } + // Fields without a value (a floor's unset choices) add nothing; no stray separator. + const fieldTexts = type.stateSchema + .map((field) => fieldText(field, part.state[field.key])) + .filter((text) => text !== ""); + if (fieldTexts.length > 0) return fieldTexts.join(" / "); if (type.kind === "BOX") { const count = part.frames?.length ?? 0; return count > 0 ? `${count} Rähmchen` : null; diff --git a/ApiCore-Frontend/src/lib/hive/describe.spec.ts b/ApiCore-Frontend/src/lib/hive/describe.spec.ts index 7d24ba0..3ad8921 100644 --- a/ApiCore-Frontend/src/lib/hive/describe.spec.ts +++ b/ApiCore-Frontend/src/lib/hive/describe.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { describeHiveOps } from "./describe"; +import { describeHiveOps, partsAddedBy } from "./describe"; import { inlineCatalog, part, templateState } from "./test-utils"; import type { HiveOp, HiveState } from "./types"; @@ -20,6 +20,19 @@ describe("describeHiveOps", () => { expect(describeHiveOps(ops, stateWithInsert(), catalog)).toBe("Bausperre entfernt, Zarge hinzugefügt"); }); + it("names parts that were added by earlier changes and are gone now", () => { + const pocket = part("p1", "frame-feeder", { slot: 1, state: { syrupLiters: 1 } }); + const earlier: HiveOp[] = [{ op: "addInsert", hostId: "b", part: pocket }]; + const seen = partsAddedBy(earlier); + const later: HiveOp[] = [ + { op: "setState", id: "p1", key: "syrupLiters", value: 1.5 }, + { op: "removePart", id: "p1" }, + ]; + + expect(describeHiveOps(later, templateState(), catalog, seen)).toBe("Flüssigfutter 1,5 l, Futtertasche entfernt"); + expect(describeHiveOps(later, templateState(), catalog)).toContain("Teil entfernt"); + }); + it("uses the label of a part when it has one", () => { const state = templateState(); state.stack[1].label = "Honigraum 1"; diff --git a/ApiCore-Frontend/src/lib/hive/describe.ts b/ApiCore-Frontend/src/lib/hive/describe.ts index 94070fc..d502cbf 100644 --- a/ApiCore-Frontend/src/lib/hive/describe.ts +++ b/ApiCore-Frontend/src/lib/hive/describe.ts @@ -63,13 +63,25 @@ function describeOne(op: HiveOp, known: Map, catalog: HiveCatalog } } +/** Parts (and their inserts) that ops put into the stack, so later texts can still name them. */ +export function partsAddedBy(ops: HiveOp[]): HivePart[] { + return ops.flatMap((op) => (op.op === "addPart" || op.op === "addInsert" ? [op.part] : [])); +} + /** * German one-line summary of a change, e.g. „Bausperre entfernt, Zarge hinzugefügt“. * Names come from `before` plus parts the same batch adds; more than three ops * are shortened to the first three and „und n weitere“. */ -export function describeHiveOps(ops: HiveOp[], before: HiveState, catalog: HiveCatalog): string { +export function describeHiveOps( + ops: HiveOp[], + before: HiveState, + catalog: HiveCatalog, + /** Parts from other changes that no longer exist in `before` (see `partsAddedBy`). */ + seen: HivePart[] = [], +): string { const known = new Map(); + for (const part of seen) collect(part, catalog, known); for (const part of before.stack) collect(part, catalog, known); const texts = ops.map((op) => describeOne(op, known, catalog)); if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");