feat: add session undo and redo
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import { createSnapshotStore } from "../../store/snapshot.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { HistoryControls } from "./HistoryControls.tsx";
|
||||
|
||||
const original = { getHistory: store.getHistory, subscribe: store.subscribe, undo: store.undo, redo: store.redo };
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root!.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
Object.assign(store, original);
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
let saved = createSeedDataset("2026-09");
|
||||
let fail = false;
|
||||
const history = createSnapshotStore({
|
||||
async read() { return saved; },
|
||||
async write(next) {
|
||||
if (fail) throw new Error("Disk full");
|
||||
saved = next;
|
||||
},
|
||||
}, "2026-09");
|
||||
await history.load();
|
||||
Object.assign(store, {
|
||||
getHistory: history.getHistory,
|
||||
subscribe: history.subscribe,
|
||||
undo: history.undo,
|
||||
redo: history.redo,
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
act(() => root!.render(<HistoryControls />));
|
||||
return { history, failWrites(value: boolean) { fail = value; } };
|
||||
}
|
||||
|
||||
test("buttons follow saved history and restore data", async () => {
|
||||
const { history } = await setup();
|
||||
const undo = container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!;
|
||||
const redo = container!.querySelector<HTMLButtonElement>('button[aria-label="Redo"]')!;
|
||||
expect(undo.disabled).toBe(true);
|
||||
expect(redo.disabled).toBe(true);
|
||||
|
||||
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
|
||||
expect(undo.disabled).toBe(false);
|
||||
await act(async () => { undo.click(); });
|
||||
expect(history.get().posts[0]!.name).toBe("Video Games");
|
||||
expect(redo.disabled).toBe(false);
|
||||
await act(async () => { redo.click(); });
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
});
|
||||
|
||||
test("a failed undo shows an error and permits retry", async () => {
|
||||
const { history, failWrites } = await setup();
|
||||
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
|
||||
failWrites(true);
|
||||
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
|
||||
expect(container!.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
failWrites(false);
|
||||
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
|
||||
expect(container!.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
test("keyboard shortcut reverses saved edits outside editable controls", async () => {
|
||||
const { history } = await setup();
|
||||
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
|
||||
const field = document.createElement("input");
|
||||
container!.append(field);
|
||||
await act(async () => {
|
||||
field.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(history.get().posts[0]!.name).toBe("Video Games");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { store } from "../../store/index.ts";
|
||||
|
||||
/** Session history controls. Dataset writes remain serialized by the store. */
|
||||
export function HistoryControls() {
|
||||
const { canUndo, canRedo } = useSyncExternalStore(store.subscribe, store.getHistory);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const run = useCallback(async (direction: "undo" | "redo") => {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
await store[direction]();
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(`Could not ${direction}: ${message}. Your data has not changed.`);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.altKey || !(event.ctrlKey || event.metaKey)) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest("input, textarea, select, [contenteditable]")) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const direction = key === "z"
|
||||
? (event.shiftKey ? "redo" : "undo")
|
||||
: key === "y" && !event.shiftKey ? "redo" : null;
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
void run(direction);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [run]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 border-r border-budget-rule pr-1.5 mr-1.5">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Undo"
|
||||
title="Undo (Ctrl/Cmd+Z)" disabled={busy || !canUndo} onClick={() => void run("undo")}>
|
||||
<Undo2 aria-hidden="true" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Redo"
|
||||
title="Redo (Ctrl/Cmd+Shift+Z)" disabled={busy || !canRedo} onClick={() => void run("redo")}>
|
||||
<Redo2 aria-hidden="true" />
|
||||
</Button>
|
||||
{error && <p role="alert" className="absolute right-4 top-full max-w-[calc(100vw-2rem)] rounded-b-md border border-t-0 border-destructive/40 bg-background px-3 py-2 text-sm text-destructive shadow-sm sm:right-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user