add undo/redo to page editors with keyboard shortcuts
All checks were successful
deploy / deploy (push) Successful in 1m29s
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:
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user