fix: Beute-Editor und Durchsicht nach Browser-Prüfung
- 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 <main> - 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) <[email protected]>
This commit is contained in:
1 parent
7a07e1cac4
commit
8166e7c621
12 files changed
+253
-36
No files matched your search
@@ -243,6 +243,51 @@ describe("HiveEditorPage", () => {
|
|||||||
expect(fetchHive).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("announces undo and redo", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await addPart("cover");
|
await addPart("cover");
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export default function HiveEditorPage() {
|
|||||||
const connectivity = useConnectivity();
|
const connectivity = useConnectivity();
|
||||||
const backHref = `/colonies/${encodeURIComponent(id)}`;
|
const backHref = `/colonies/${encodeURIComponent(id)}`;
|
||||||
const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(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.
|
// A colony without a hive answers 404 for good; no point retrying.
|
||||||
retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3,
|
retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3,
|
||||||
});
|
});
|
||||||
@@ -72,6 +74,7 @@ export default function HiveEditorPage() {
|
|||||||
setup={hive.data}
|
setup={hive.data}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
canManageTemplates={hasPermission("hive-systems:manage")}
|
canManageTemplates={hasPermission("hive-systems:manage")}
|
||||||
|
onRefresh={() => void hive.refetch()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,17 +140,19 @@ export function InspectionHiveSection({
|
|||||||
catalog,
|
catalog,
|
||||||
errorText,
|
errorText,
|
||||||
}: InspectionHiveSectionProps) {
|
}: InspectionHiveSectionProps) {
|
||||||
const sectionRef = useRef<HTMLDivElement>(null);
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
const [outlineShown, setOutlineShown] = useState(false);
|
||||||
const [announcement, setAnnouncement] = useState("");
|
const [announcement, setAnnouncement] = useState("");
|
||||||
return (
|
return (
|
||||||
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
||||||
<div
|
<div
|
||||||
ref={sectionRef}
|
|
||||||
data-testid="hive-section"
|
data-testid="hive-section"
|
||||||
tabIndex={-1}
|
|
||||||
className="flex flex-col gap-4 outline-none"
|
className="flex flex-col gap-4 outline-none"
|
||||||
onSubmit={(event) => event.stopPropagation()}
|
onSubmit={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<h3 ref={headingRef} tabIndex={-1} className="sr-only">
|
||||||
|
Beute-Änderungen
|
||||||
|
</h3>
|
||||||
<p role="status" aria-live="polite" className="sr-only">
|
<p role="status" aria-live="polite" className="sr-only">
|
||||||
{announcement}
|
{announcement}
|
||||||
</p>
|
</p>
|
||||||
@@ -162,6 +164,8 @@ export function InspectionHiveSection({
|
|||||||
mode={canEdit ? "inspection" : "view"}
|
mode={canEdit ? "inspection" : "view"}
|
||||||
onOpsChange={canEdit ? onOpsChange : undefined}
|
onOpsChange={canEdit ? onOpsChange : undefined}
|
||||||
initialOps={canEdit ? ops : undefined}
|
initialOps={canEdit ? ops : undefined}
|
||||||
|
initialShowOutline={outlineShown}
|
||||||
|
onShowOutlineChange={setOutlineShown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && ops.length > 0 && base && catalog ? (
|
{canEdit && ops.length > 0 && base && catalog ? (
|
||||||
@@ -171,7 +175,7 @@ export function InspectionHiveSection({
|
|||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
onChange={onOpsReplace}
|
onChange={onOpsReplace}
|
||||||
onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)}
|
onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)}
|
||||||
onEmptied={() => sectionRef.current?.focus()}
|
onEmptied={() => headingRef.current?.focus()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{errorText ? (
|
{errorText ? (
|
||||||
|
|||||||
@@ -50,10 +50,16 @@ vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
|||||||
fetchHiveSystems: vi.fn(),
|
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", () => ({
|
vi.mock("@/components/hive/hive-card", () => ({
|
||||||
HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => {
|
HiveCard: (props: {
|
||||||
card.renders.push({ mode: props.mode, initialOps: props.initialOps });
|
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 fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
|
||||||
const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
|
const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
|
||||||
const add = {
|
const add = {
|
||||||
@@ -73,6 +79,9 @@ vi.mock("@/components/hive/hive-card", () => ({
|
|||||||
<button type="button" onClick={() => props.onOpsChange?.([add, fillNew])}>
|
<button type="button" onClick={() => props.onOpsChange?.([add, fillNew])}>
|
||||||
Test neue Zarge
|
Test neue Zarge
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => props.onShowOutlineChange?.(true)}>
|
||||||
|
Test Liste
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => props.onOpsChange?.([fill, fill])}>
|
<button type="button" onClick={() => props.onOpsChange?.([fill, fill])}>
|
||||||
Test zwei gleiche
|
Test zwei gleiche
|
||||||
</button>
|
</button>
|
||||||
@@ -217,7 +226,7 @@ describe("NewInspectionPage: section Beute", () => {
|
|||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
|
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 () => {
|
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();
|
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 () => {
|
it("hides a refusal once the ops changed", async () => {
|
||||||
vi.mocked(createInspection).mockRejectedValue(
|
vi.mocked(createInspection).mockRejectedValue(
|
||||||
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
|
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react";
|
import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react";
|
||||||
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
|
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ export interface EditorToolbarProps {
|
|||||||
title: string;
|
title: string;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
|
undoRef?: Ref<HTMLButtonElement>;
|
||||||
|
redoRef?: Ref<HTMLButtonElement>;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
date: string;
|
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. */
|
/** 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 { date, today, hasOps, saving } = props;
|
||||||
const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today;
|
const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today;
|
||||||
return (
|
return (
|
||||||
<header className={`flex flex-wrap items-center gap-2 px-3 py-2 ${GLASS_SURFACE}`}>
|
<header className={`flex flex-wrap items-center gap-2 px-3 py-2 ${GLASS_SURFACE}`}>
|
||||||
{props.back}
|
{props.back}
|
||||||
<h1 className="mr-auto text-lg font-semibold tracking-tight">{props.title}</h1>
|
<h1 className="mr-auto text-lg font-semibold tracking-tight">{props.title}</h1>
|
||||||
<button type="button" onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
|
<button type="button" ref={undoRef} onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
|
||||||
<Undo2 aria-hidden />
|
<Undo2 aria-hidden />
|
||||||
Rückgängig
|
Rückgängig
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={redoRef}
|
||||||
onClick={props.onRedo}
|
onClick={props.onRedo}
|
||||||
disabled={!props.canRedo}
|
disabled={!props.canRedo}
|
||||||
className={GLASS_BUTTON}
|
className={GLASS_BUTTON}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -36,13 +36,43 @@ export interface HiveEditorProps {
|
|||||||
catalog: HiveCatalog;
|
catalog: HiveCatalog;
|
||||||
/** hive-systems:manage: may store the build as the system template. */
|
/** hive-systems:manage: may store the build as the system template. */
|
||||||
canManageTemplates: boolean;
|
canManageTemplates: boolean;
|
||||||
|
/** Reloads the setup; called on focus and every 30 s while a draft exists, so a change elsewhere shows. */
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
const STALE_HINT =
|
const STALE_HINT =
|
||||||
"Die Beute wurde inzwischen geändert. Beim Speichern werden deine Schritte auf den aktuellen Stand angewendet.";
|
"Die Beute wurde inzwischen geändert. Beim Speichern werden deine Schritte auf den aktuellen Stand angewendet.";
|
||||||
|
|
||||||
|
/** Row order of the stack list (top to bottom, inserts under their host). */
|
||||||
|
function listOrder(state: HiveState): string[] {
|
||||||
|
return [...state.stack].reverse().flatMap((part) => [part.id, ...(part.inserts ?? []).map((insert) => insert.id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows to focus after `id` is removed: the next one, else the previous one (never a removed one). */
|
||||||
|
function neighbourIds(state: HiveState, id: string): string[] {
|
||||||
|
const order = listOrder(state);
|
||||||
|
const host = state.stack.find((part) => part.id === id);
|
||||||
|
const gone = new Set([id, ...(host?.inserts ?? []).map((insert) => insert.id)]);
|
||||||
|
const index = order.indexOf(id);
|
||||||
|
const after = order.slice(index + 1).filter((other) => !gone.has(other));
|
||||||
|
const before = order.slice(0, index).reverse().filter((other) => !gone.has(other));
|
||||||
|
return [...(after[0] ? [after[0]] : []), ...(before[0] ? [before[0]] : [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowControl(id: string): HTMLElement | null {
|
||||||
|
return document.querySelector<HTMLElement>(`[data-part-id="${CSS.escape(id)}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The history button that still works, else the stack heading. */
|
||||||
|
function historyFocusTarget(used: HTMLElement | null, other: HTMLElement | null, heading: HTMLElement | null): HTMLElement | null {
|
||||||
|
const enabled = (element: HTMLElement | null) => (element instanceof HTMLButtonElement && !element.disabled ? element : null);
|
||||||
|
return enabled(used) ?? enabled(other) ?? heading;
|
||||||
|
}
|
||||||
|
|
||||||
function hasPart(state: HiveState, id: string): boolean {
|
function hasPart(state: HiveState, id: string): boolean {
|
||||||
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
|
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
|
||||||
}
|
}
|
||||||
@@ -66,7 +96,7 @@ export function HiveEditor(props: HiveEditorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEditorProps) {
|
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefresh }: HiveEditorProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const draft = useHiveDraft(setup.state, catalog);
|
const draft = useHiveDraft(setup.state, catalog);
|
||||||
@@ -86,6 +116,12 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
);
|
);
|
||||||
const [confirm, setConfirm] = useState<"discard" | "leave" | "template" | null>(null);
|
const [confirm, setConfirm] = useState<"discard" | "leave" | "template" | null>(null);
|
||||||
|
|
||||||
|
const undoRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const redoRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const stackHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
// What to focus once the next render has settled (keys of rows, buttons that ran out).
|
||||||
|
const pendingFocus = useRef<(() => HTMLElement | null) | null>(null);
|
||||||
|
|
||||||
const hasOps = draft.ops.length > 0;
|
const hasOps = draft.ops.length > 0;
|
||||||
// A selection ends with its part (removed or undone).
|
// A selection ends with its part (removed or undone).
|
||||||
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null;
|
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null;
|
||||||
@@ -110,7 +146,20 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
},
|
},
|
||||||
[draft, catalog, announce],
|
[draft, catalog, announce],
|
||||||
);
|
);
|
||||||
const apply = useCallback((op: HiveOp | HiveOp[]) => applyStep(op, false), [applyStep]);
|
const apply = useCallback(
|
||||||
|
(op: HiveOp | HiveOp[]) => {
|
||||||
|
const ops = Array.isArray(op) ? op : [op];
|
||||||
|
const removal = ops.length === 1 && ops[0].op === "removePart" ? ops[0] : null;
|
||||||
|
const targets = removal ? neighbourIds(draft.state, removal.id) : [];
|
||||||
|
const outcome = applyStep(op, false);
|
||||||
|
if (outcome.ok && removal) {
|
||||||
|
pendingFocus.current = () =>
|
||||||
|
targets.map(rowControl).find((element) => element !== null) ?? stackHeadingRef.current;
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
},
|
||||||
|
[applyStep, draft.state],
|
||||||
|
);
|
||||||
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
|
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
|
||||||
|
|
||||||
const { undo: undoDraft, redo: redoDraft } = draft;
|
const { undo: undoDraft, redo: redoDraft } = draft;
|
||||||
@@ -120,13 +169,48 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
);
|
);
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
const step = undoDraft();
|
const step = undoDraft();
|
||||||
if (step) announce(describeStep("Rückgängig", step));
|
if (!step) return;
|
||||||
|
announce(describeStep("Rückgängig", step));
|
||||||
|
pendingFocus.current = () => historyFocusTarget(undoRef.current, redoRef.current, stackHeadingRef.current);
|
||||||
}, [undoDraft, announce, describeStep]);
|
}, [undoDraft, announce, describeStep]);
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
const step = redoDraft();
|
const step = redoDraft();
|
||||||
if (step) announce(describeStep("Wiederholt", step));
|
if (!step) return;
|
||||||
|
announce(describeStep("Wiederholt", step));
|
||||||
|
pendingFocus.current = () => historyFocusTarget(redoRef.current, undoRef.current, stackHeadingRef.current);
|
||||||
}, [redoDraft, announce, describeStep]);
|
}, [redoDraft, announce, describeStep]);
|
||||||
|
|
||||||
|
// A button that ran out of steps (or a removed row) must not take the keyboard focus with it.
|
||||||
|
useEffect(() => {
|
||||||
|
const resolve = pendingFocus.current;
|
||||||
|
if (!resolve) return;
|
||||||
|
pendingFocus.current = null;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const lost = !active || active === document.body || (active instanceof HTMLButtonElement && active.disabled);
|
||||||
|
if (lost || !active?.isConnected) resolve()?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Learn about changes made elsewhere: refresh on focus and, while a draft exists, every 30 s.
|
||||||
|
const refreshRef = useRef(onRefresh);
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRef.current = onRefresh;
|
||||||
|
});
|
||||||
|
const canRefresh = onRefresh !== undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canRefresh) return;
|
||||||
|
const refresh = () => {
|
||||||
|
if (document.visibilityState !== "hidden") refreshRef.current?.();
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", refresh);
|
||||||
|
document.addEventListener("visibilitychange", refresh);
|
||||||
|
const timer = hasOps ? window.setInterval(refresh, REFRESH_INTERVAL_MS) : undefined;
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", refresh);
|
||||||
|
document.removeEventListener("visibilitychange", refresh);
|
||||||
|
if (timer !== undefined) window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [canRefresh, hasOps]);
|
||||||
|
|
||||||
// Leaving the page (reload, close, outside link) with open changes asks first.
|
// Leaving the page (reload, close, outside link) with open changes asks first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasOps) return;
|
if (!hasOps) return;
|
||||||
@@ -199,7 +283,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
|
<main className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
back={
|
back={
|
||||||
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
|
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
|
||||||
@@ -210,6 +294,8 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
title="Umbauen"
|
title="Umbauen"
|
||||||
canUndo={draft.canUndo}
|
canUndo={draft.canUndo}
|
||||||
canRedo={draft.canRedo}
|
canRedo={draft.canRedo}
|
||||||
|
undoRef={undoRef}
|
||||||
|
redoRef={redoRef}
|
||||||
onUndo={undo}
|
onUndo={undo}
|
||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
date={date}
|
date={date}
|
||||||
@@ -254,7 +340,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
<PartPalette catalog={catalog} onAdd={openAdd} />
|
<PartPalette catalog={catalog} onAdd={openAdd} />
|
||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
|
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
|
||||||
<h2 id="editor-stack" className="text-base font-semibold">
|
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none">
|
||||||
Aufbau
|
Aufbau
|
||||||
</h2>
|
</h2>
|
||||||
<EditorStackList
|
<EditorStackList
|
||||||
@@ -309,6 +395,6 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
|
|||||||
onConfirm={() => templateMutation.mutate()}
|
onConfirm={() => templateMutation.mutate()}
|
||||||
/>
|
/>
|
||||||
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={resolveConflict} />
|
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={resolveConflict} />
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,9 @@ export interface HiveCardProps {
|
|||||||
* loaded hive; ignored in view mode.
|
* loaded hive; ignored in view mode.
|
||||||
*/
|
*/
|
||||||
initialOps?: HiveOp[];
|
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();
|
const { hasPermission } = useAuth();
|
||||||
// Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10).
|
// Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10).
|
||||||
const hive = useHiveSetup(colonyId);
|
const hive = useHiveSetup(colonyId);
|
||||||
@@ -144,6 +147,8 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
|
|||||||
mode={mode}
|
mode={mode}
|
||||||
onOpsChange={onOpsChange}
|
onOpsChange={onOpsChange}
|
||||||
initialOps={initialOps}
|
initialOps={initialOps}
|
||||||
|
initialShowOutline={initialShowOutline}
|
||||||
|
onShowOutlineChange={onShowOutlineChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -207,6 +212,8 @@ interface HiveCardBodyProps {
|
|||||||
mode: "view" | "inspection";
|
mode: "view" | "inspection";
|
||||||
onOpsChange?: (ops: HiveOp[]) => void;
|
onOpsChange?: (ops: HiveOp[]) => void;
|
||||||
initialOps?: HiveOp[];
|
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. */
|
/** 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 : [];
|
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 view = useHiveView();
|
||||||
const connectivity = useConnectivity();
|
const connectivity = useConnectivity();
|
||||||
const mutation = useHiveMutation(colonyId, catalog);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [seedDropped]);
|
}, [seedDropped]);
|
||||||
const [past, setPast] = useState<PastState | null>(null);
|
const [past, setPast] = useState<PastState | null>(null);
|
||||||
const [showOutline, setShowOutline] = useState(false);
|
const [showOutline, setShowOutline] = useState(initialShowOutline);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [frameTarget, setFrameTarget] = useState<FrameTarget | null>(null);
|
const [frameTarget, setFrameTarget] = useState<FrameTarget | null>(null);
|
||||||
const [frameOpen, setFrameOpen] = useState(false);
|
const [frameOpen, setFrameOpen] = useState(false);
|
||||||
@@ -380,7 +387,10 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
|
|||||||
aria-pressed={showOutline}
|
aria-pressed={showOutline}
|
||||||
aria-controls={outlineId}
|
aria-controls={outlineId}
|
||||||
aria-label="Aufbau als Liste"
|
aria-label="Aufbau als Liste"
|
||||||
onClick={() => setShowOutline(!showOutline)}
|
onClick={() => {
|
||||||
|
setShowOutline(!showOutline);
|
||||||
|
onShowOutlineChange?.(!showOutline);
|
||||||
|
}}
|
||||||
className={GLASS_BUTTON}
|
className={GLASS_BUTTON}
|
||||||
>
|
>
|
||||||
<ListTree aria-hidden />
|
<ListTree aria-hidden />
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { berlinCalendarDay } from "@/lib/dates";
|
import { berlinCalendarDay } from "@/lib/dates";
|
||||||
import { fetchHiveAt, fetchHiveHistory, hiveKeys } from "@/lib/hive/api";
|
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 { 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.";
|
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 {
|
function opsOf(item: HiveChangeListItem): HiveOp[] {
|
||||||
const ops = Array.isArray(item.ops) ? (item.ops as HiveOp[]) : [];
|
return 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 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;
|
if (text) return text;
|
||||||
return item.source === "INIT" ? "Beute angelegt" : "Änderung";
|
return item.source === "INIT" ? "Beute angelegt" : "Änderung";
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis
|
|||||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
|
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
const seen = items.flatMap((item) => partsAddedBy(opsOf(item)));
|
||||||
|
|
||||||
async function show(item: HiveChangeListItem) {
|
async function show(item: HiveChangeListItem) {
|
||||||
setLoadingId(item.id);
|
setLoadingId(item.id);
|
||||||
@@ -94,7 +99,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis
|
|||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
|
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
|
||||||
</span>
|
</span>
|
||||||
<span>{textOf(item, state, catalog)}</span>
|
<span>{textOf(item, state, catalog, seen)}</span>
|
||||||
{isLate ? (
|
{isLate ? (
|
||||||
<span className="text-xs text-muted-foreground">nachgetragen am {shortGermanDay(enteredOn)}</span>
|
<span className="text-xs text-muted-foreground">nachgetragen am {shortGermanDay(enteredOn)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ describe("HiveOutline", () => {
|
|||||||
expect(items.findIndex((t) => t?.includes("Zarge 2"))).toBeLessThan(items.findIndex((t) => t?.includes("Zarge 1")));
|
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", () => {
|
it("shows a short state hint per part", () => {
|
||||||
setup();
|
setup();
|
||||||
const text = names().join("|");
|
const text = names().join("|");
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] {
|
|||||||
|
|
||||||
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
|
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
if (type.stateSchema.length > 0) {
|
// Fields without a value (a floor's unset choices) add nothing; no stray separator.
|
||||||
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).join(" / ");
|
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") {
|
if (type.kind === "BOX") {
|
||||||
const count = part.frames?.length ?? 0;
|
const count = part.frames?.length ?? 0;
|
||||||
return count > 0 ? `${count} Rähmchen` : null;
|
return count > 0 ? `${count} Rähmchen` : null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describeHiveOps } from "./describe";
|
import { describeHiveOps, partsAddedBy } from "./describe";
|
||||||
import { inlineCatalog, part, templateState } from "./test-utils";
|
import { inlineCatalog, part, templateState } from "./test-utils";
|
||||||
import type { HiveOp, HiveState } from "./types";
|
import type { HiveOp, HiveState } from "./types";
|
||||||
|
|
||||||
@@ -20,6 +20,19 @@ describe("describeHiveOps", () => {
|
|||||||
expect(describeHiveOps(ops, stateWithInsert(), catalog)).toBe("Bausperre entfernt, Zarge hinzugefügt");
|
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", () => {
|
it("uses the label of a part when it has one", () => {
|
||||||
const state = templateState();
|
const state = templateState();
|
||||||
state.stack[1].label = "Honigraum 1";
|
state.stack[1].label = "Honigraum 1";
|
||||||
|
|||||||
@@ -63,13 +63,25 @@ function describeOne(op: HiveOp, known: Map<string, Named>, 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“.
|
* 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
|
* Names come from `before` plus parts the same batch adds; more than three ops
|
||||||
* are shortened to the first three and „und n weitere“.
|
* 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<string, Named>();
|
const known = new Map<string, Named>();
|
||||||
|
for (const part of seen) collect(part, catalog, known);
|
||||||
for (const part of before.stack) collect(part, catalog, known);
|
for (const part of before.stack) collect(part, catalog, known);
|
||||||
const texts = ops.map((op) => describeOne(op, known, catalog));
|
const texts = ops.map((op) => describeOne(op, known, catalog));
|
||||||
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
|
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
|
||||||
|
|||||||
Reference in new issue
Block a user