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)}

View File

@@ -215,6 +215,88 @@ defmodule BerrypodWeb.PageEditorHookTest 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, "/?edit=true")
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
view
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
# After move_up, second block is now first
first_html = view |> element(".block-card:first-child") |> render()
refute first_html =~ "Hero"
# Undo
view |> element("button[phx-click='editor_undo']") |> render_click()
# Hero should be back as first block
restored_first = view |> element(".block-card:first-child") |> render()
assert restored_first =~ "Hero"
end
test "redo restores an undone change", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
view
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
view |> element("button[phx-click='editor_undo']") |> render_click()
view |> element("button[phx-click='editor_redo']") |> render_click()
# After redo, hero should no longer be first
first_html = view |> element(".block-card:first-child") |> render()
refute first_html =~ "Hero"
end
test "history clears on save", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
view
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
view |> element("button[phx-click='editor_save']") |> render_click()
# After save, undo should be disabled
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
end
test "undo/redo buttons reflect stack state", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Initially both disabled
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
assert has_element?(view, "button[phx-click='editor_redo'][disabled]")
# Make a change
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
view
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
# Undo enabled, redo still disabled
refute has_element?(view, "button[phx-click='editor_undo'][disabled]")
assert has_element?(view, "button[phx-click='editor_redo'][disabled]")
end
end
describe "content pages (deferred init)" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}