add undo/redo to page editors with keyboard shortcuts
All checks were successful
deploy / deploy (push) Successful in 1m29s

History stacks (@history/@future) on both admin editor and live sidebar,
capped at 50 entries. All mutations routed through apply_mutation for
consistent history tracking. EditorKeyboard JS hook combines DirtyGuard
with Ctrl+Z/Ctrl+Shift+Z. Settings panel fade-in animation. 10 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-28 12:16:15 +00:00
parent 22d7b0e92b
commit 79b5161e02
8 changed files with 379 additions and 37 deletions

View File

@@ -267,6 +267,113 @@ defmodule BerrypodWeb.Admin.PagesTest do
end
end
describe "undo and redo" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "undo reverts the last change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
page = Pages.get_page("home")
block = Enum.at(page.blocks, 1)
render_click(view, "remove_block", %{"id" => block["id"]})
# Block should be gone
refute has_element?(view, ".block-card-name", "Category navigation")
# Undo
render_click(view, "undo")
# Block should be back
assert has_element?(view, ".block-card-name", "Category navigation")
assert has_element?(view, "[aria-live='polite']", "Undone")
end
test "redo restores an undone change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
page = Pages.get_page("home")
block = Enum.at(page.blocks, 1)
render_click(view, "remove_block", %{"id" => block["id"]})
render_click(view, "undo")
assert has_element?(view, ".block-card-name", "Category navigation")
render_click(view, "redo")
refute has_element?(view, ".block-card-name", "Category navigation")
assert has_element?(view, "[aria-live='polite']", "Redone")
end
test "new mutation clears redo stack", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
page = Pages.get_page("home")
hero = Enum.at(page.blocks, 0)
cat_nav = Enum.at(page.blocks, 1)
# Make a change, undo, then make a different change
render_click(view, "remove_block", %{"id" => cat_nav["id"]})
render_click(view, "undo")
render_click(view, "move_down", %{"id" => hero["id"]})
# Redo should do nothing (stack was cleared)
render_click(view, "redo")
# Hero should still be at position 2 (the move_down result)
assert has_element?(view, "[aria-live='polite']", "Hero banner moved to position 2")
end
test "undo all the way back clears dirty flag", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
page = Pages.get_page("home")
hero = Enum.at(page.blocks, 0)
render_click(view, "move_down", %{"id" => hero["id"]})
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
render_click(view, "undo")
refute has_element?(view, ".admin-badge-warning", "Unsaved changes")
end
test "undo when history empty does nothing", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
# No changes made, undo should be fine
render_click(view, "undo")
# Should still render normally
assert has_element?(view, ".block-card-name", "Hero banner")
end
test "undo/redo buttons reflect stack state", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
# Initially both disabled
assert has_element?(view, "button[phx-click='undo'][disabled]")
assert has_element?(view, "button[phx-click='redo'][disabled]")
# Make a change
page = Pages.get_page("home")
hero = Enum.at(page.blocks, 0)
render_click(view, "move_down", %{"id" => hero["id"]})
# Undo enabled, redo still disabled
refute has_element?(view, "button[phx-click='undo'][disabled]")
assert has_element?(view, "button[phx-click='redo'][disabled]")
# Undo
render_click(view, "undo")
# Undo disabled again, redo now enabled
assert has_element?(view, "button[phx-click='undo'][disabled]")
refute has_element?(view, "button[phx-click='redo'][disabled]")
end
end
describe "page editor for page-specific pages" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}