Dashboard editor undo: ⌘Z / ⇧⌘Z over a 50-entry history of commits, folding keystrokes and the grid's own correction as the flow editor does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 00:07:58 +02:00
co-authored by Claude Opus 5.5
parent 0a9ea24b93
commit 98416dc516
3 changed files with 87 additions and 3 deletions
+1
View File
@@ -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!
@@ -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<History>({ 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": () => {
+24
View File
@@ -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)
})