From 98416dc516e6cb1346755c7e352f0f595c3f02fa Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 00:07:58 +0200 Subject: [PATCH] =?UTF-8?q?Dashboard=20editor=20undo:=20=E2=8C=98Z=20/=20?= =?UTF-8?q?=E2=87=A7=E2=8C=98Z=20over=20a=2050-entry=20history=20of=20comm?= =?UTF-8?q?its,=20folding=20keystrokes=20and=20the=20grid's=20own=20correc?= =?UTF-8?q?tion=20as=20the=20flow=20editor=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- docs/interface/dashboards.md | 1 + .../components/Dashboard/DashboardEditor.tsx | 65 ++++++++++++++++++- frontend/tests/editor.spec.ts | 24 +++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 0997e2b..811dd30 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -14,6 +14,7 @@ to a paired screen, such as a wall tablet. Edits stay in a draft; paired screens show the published version. On phones, tiles stack in one column and dragging is disabled, but settings remain editable. +In edit mode, ⌘Z / Ctrl-Z undoes an edit and ⌘⇧Z / Ctrl-Shift-Z redoes it. Controls can publish real values while you edit, so choose their targets with care. Your new Switch may already be flipping something! diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 78bb103..ed4edae 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -288,6 +288,41 @@ const scaledStrategy = (scale: number) => ({ calcStyle: setTransform as (pos: Position) => CSSProperties, }) +/** Dashboards to step back and forward through, newest last. */ +type History = { + past: Dashboard[] + future: Dashboard[] + /** When the last entry was recorded, so a burst of typing stays one edit. */ + at: number +} + +const HISTORY_LIMIT = 50 +const TYPING_WINDOW = 500 + +/** + * Remember the dashboard as it was before a change. + * + * Fields commit on every keystroke, and the grid writes back its own + * correction a moment after a widget comes or goes, so consecutive edits that + * leave the same widgets in place fold into the entry already on the stack. + * Adding, removing or reordering a widget is a finished action and starts its + * own. + */ +function record(history: History, previous: Dashboard, next: Dashboard) { + const before = widgetsOf(previous) + const after = widgetsOf(next) + const sameWidgets = + before.length === after.length && + before.every((widget, index) => widget.id === after[index].id) + if (!sameWidgets || Date.now() - history.at >= TYPING_WINDOW) { + history.past.push(previous) + if (history.past.length > HISTORY_LIMIT) history.past.shift() + } + history.at = Date.now() + // A new change is a new branch: what was undone is not coming back. + history.future = [] +} + const same = (a: Layout, b: Layout) => a.length === b.length && a.every((item, index) => { @@ -349,6 +384,7 @@ export function DashboardEditor({ // The saved version is what the next save is based on; without following it // the second save of a session is always a conflict. const version = useRef(dashboard.version) + const history = useRef({ past: [], future: [], at: 0 }) useEffect(() => { version.current = dashboard.version @@ -369,7 +405,8 @@ export function DashboardEditor({ return request } - const commit = (next: Dashboard) => { + /** Put a dashboard on the canvas and on its way to the server. */ + const apply = (next: Dashboard) => { setDraft(next) if (timer.current) clearTimeout(timer.current) timer.current = setTimeout(() => { @@ -378,6 +415,25 @@ export function DashboardEditor({ }, AUTOSAVE_MS) } + /** Apply a change and make it undoable. */ + const commit = (next: Dashboard) => { + record(history.current, draft, next) + apply(next) + } + + /** + * Step through the history. Undo and redo save like any other edit, so the + * autosave debounce collapses a run of them into one write. + */ + const step = (back: boolean) => { + const { past, future } = history.current + const remembered = (back ? past : future).pop() + if (!remembered) return + ;(back ? future : past).push(draft) + history.current.at = 0 + apply(remembered) + } + /** Send what is queued and wait for the server to have it. */ const flush = async () => { if (timer.current) { @@ -439,10 +495,13 @@ export function DashboardEditor({ setSettingsOpen(true) } - // As on the flow canvas, and reachable while typing for the same reasons. - // Outside edit mode both keys stay the browser's. + // As on the flow canvas: undo and redo step aside while typing, since a + // field keeps its own, and ⌘P and ⌘S stay reachable for the same reasons. + // Outside edit mode every key stays the browser's. useShortcuts( { + "mod+z": () => (edit ? step(true) : false), + "mod+shift+z": () => (edit ? step(false) : false), "mod+p": () => (edit ? setPaletteOpen((open) => !open) : false), // An edit still waiting for its autosave counts: publishing flushes it. "mod+s": () => { diff --git a/frontend/tests/editor.spec.ts b/frontend/tests/editor.spec.ts index 9cc1a86..51470cf 100644 --- a/frontend/tests/editor.spec.ts +++ b/frontend/tests/editor.spec.ts @@ -11,6 +11,8 @@ import { api, apiPage, deleteAll } from "./utils/api" * * The third is what a widget drawn without its title is dragged by, since the * header it used to be dragged by is the thing that is gone. + * + * The last is the undo a mis-click needs: a removed widget comes back. */ const dashboardName = `test_editor_${Date.now().toString(36)}` @@ -201,3 +203,25 @@ test("a palette is pasted as a link and read as roles", async ({ page }) => { "", ) }) + +test("a removed widget comes back on undo and goes again on redo", async ({ + page, +}) => { + await openEditor(page) + const frames = page.getByTestId("widget-frame") + const top = frames.filter({ hasText: "Top" }) + + await top.click() + await page.getByTestId("delete-widget").click() + await expect(top).toHaveCount(0) + + // Removing closes the panel, so the keys reach the canvas rather than a + // field, which keeps its own undo. + await page.keyboard.press("ControlOrMeta+z") + await expect(top).toBeVisible() + await expect(frames).toHaveCount(2) + + await page.keyboard.press("ControlOrMeta+Shift+z") + await expect(top).toHaveCount(0) + await expect(frames).toHaveCount(1) +})