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

@ -147,7 +147,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~98~~ | ~~Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling~~ | 97 | 1h | done | | ~~98~~ | ~~Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling~~ | 97 | 1h | done |
| ~~99~~ | ~~Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index~~ | 98 | 2.5h | done | | ~~99~~ | ~~Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index~~ | 98 | 2.5h | done |
| ~~100~~ | ~~Stage 4: navigation management — data-driven nav, settings storage, admin editor~~ | 99 | 3h | done | | ~~100~~ | ~~Stage 4: navigation management — data-driven nav, settings storage, admin editor~~ | 99 | 3h | done |
| 101 | Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published | 100 | 1h | planned | | ~~101~~ | ~~Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published~~ | 100 | 1h | done |
| 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred | | 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred |
| | **Platform site** | | | | | | **Platform site** | | | |
| 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned | | 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned |
@ -487,7 +487,7 @@ Admin media library at `/admin/media` with image grid, type/search/orphan filter
See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan
### Page Editor ### Page Editor
**Status:** In progress — Stage 8 of 9 complete, 1455 tests **Status:** Complete — all 9 stages done, 1485 tests
Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven). Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven).
@ -501,7 +501,7 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`) 7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`)
7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`) 7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`)
8. ~~Live page editor sidebar — collapsible sidebar on shop pages, backdrop dismiss, portable content block~~ ✅ (`a039c8d`) 8. ~~Live page editor sidebar — collapsible sidebar on shop pages, backdrop dismiss, portable content block~~ ✅ (`a039c8d`)
9. **Next →** Undo/redo + polish — history stacks, keyboard shortcuts, animations 9. ~~Undo/redo + polish — history stacks, keyboard shortcuts, settings animations~~ ✅
**Key files created:** **Key files created:**
- `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data)

View File

@ -1475,6 +1475,12 @@
padding: 0.75rem 0.75rem 0.25rem; padding: 0.75rem 0.75rem 0.25rem;
padding-left: 2.75rem; padding-left: 2.75rem;
border-top: 1px solid var(--t-border-default); border-top: 1px solid var(--t-border-default);
animation: blockSettingsFadeIn 0.15s ease;
}
@keyframes blockSettingsFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
} }
.block-settings-fields { .block-settings-fields {

View File

@ -651,10 +651,41 @@ const DirtyGuard = {
} }
} }
// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
const EditorKeyboard = {
mounted() {
this._beforeUnload = (e) => {
if (this.el.dataset.dirty === "true") {
e.preventDefault()
e.returnValue = ""
}
}
window.addEventListener("beforeunload", this._beforeUnload)
const prefix = this.el.dataset.eventPrefix || ""
this._keydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
e.preventDefault()
if (e.shiftKey) {
this.pushEvent(prefix + "redo")
} else {
this.pushEvent(prefix + "undo")
}
}
}
document.addEventListener("keydown", this._keydown)
},
destroyed() {
window.removeEventListener("beforeunload", this._beforeUnload)
document.removeEventListener("keydown", this._keydown)
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard},
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -26,6 +26,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:page_data, page) |> assign(:page_data, page)
|> assign(:blocks, page.blocks) |> assign(:blocks, page.blocks)
|> assign(:allowed_blocks, allowed_blocks) |> assign(:allowed_blocks, allowed_blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false) |> assign(:dirty, false)
|> assign(:show_picker, false) |> assign(:show_picker, false)
|> assign(:picker_filter, "") |> assign(:picker_filter, "")
@ -85,10 +87,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:ok, new_blocks, message} -> {:ok, new_blocks, message} ->
{:noreply, {:noreply,
socket socket
|> assign(:blocks, new_blocks) |> apply_mutation(new_blocks, message)
|> assign(:dirty, true) |> assign(:show_picker, false)}
|> assign(:show_picker, false)
|> assign(:live_region_message, message)}
:noop -> :noop ->
{:noreply, socket} {:noreply, socket}
@ -101,10 +101,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} -> {:ok, new_blocks} ->
{:noreply, {:noreply, apply_mutation(socket, new_blocks, "Settings updated")}
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)}
:noop -> :noop ->
{:noreply, socket} {:noreply, socket}
@ -191,8 +188,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:ok, new_blocks} -> {:ok, new_blocks} ->
{:noreply, {:noreply,
socket socket
|> assign(:blocks, new_blocks) |> apply_mutation(new_blocks, "Image selected")
|> assign(:dirty, true)
|> assign(:image_picker_block_id, nil) |> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)} |> assign(:image_picker_field_key, nil)}
@ -208,10 +204,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
) do ) do
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
{:ok, new_blocks} -> {:ok, new_blocks} ->
{:noreply, {:noreply, apply_mutation(socket, new_blocks, "Image cleared")}
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)}
:noop -> :noop ->
{:noreply, socket} {:noreply, socket}
@ -282,16 +275,54 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:noreply, {:noreply,
socket socket
|> assign(:blocks, page.blocks) |> assign(:blocks, page.blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false) |> assign(:dirty, false)
|> put_flash(:info, "Page reset to defaults")} |> put_flash(:info, "Page reset to defaults")}
end end
def handle_event("undo", _params, socket) do
case socket.assigns.history do
[prev | rest] ->
future = [socket.assigns.blocks | socket.assigns.future]
{:noreply,
socket
|> assign(:blocks, prev)
|> assign(:history, rest)
|> assign(:future, future)
|> assign(:dirty, prev != socket.assigns.page_data.blocks)
|> assign(:live_region_message, "Undone")}
[] ->
{:noreply, socket}
end
end
def handle_event("redo", _params, socket) do
case socket.assigns.future do
[next | rest] ->
history = [socket.assigns.blocks | socket.assigns.history]
{:noreply,
socket
|> assign(:blocks, next)
|> assign(:history, history)
|> assign(:future, rest)
|> assign(:dirty, true)
|> assign(:live_region_message, "Redone")}
[] ->
{:noreply, socket}
end
end
# ── Render ─────────────────────────────────────────────────────── # ── Render ───────────────────────────────────────────────────────
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id="page-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}> <div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link <.link
navigate={~p"/admin/pages"} navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline" class="text-sm font-normal text-base-content/60 hover:underline"
@ -318,6 +349,22 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
> >
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings <.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link> </.link>
<button
phx-click="undo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]}
disabled={@history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="redo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @future == [] && "opacity-30"]}
disabled={@future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button <button
:if={!@is_custom_page} :if={!@is_custom_page}
phx-click="reset_defaults" phx-click="reset_defaults"
@ -532,8 +579,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
# ── Helpers ────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────
defp apply_mutation(socket, new_blocks, message) do defp apply_mutation(socket, new_blocks, message) do
history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50)
socket socket
|> assign(:blocks, new_blocks) |> assign(:blocks, new_blocks)
|> assign(:history, history)
|> assign(:future, [])
|> assign(:dirty, true) |> assign(:dirty, true)
|> assign(:live_region_message, message) |> assign(:live_region_message, message)
end end

View File

@ -27,6 +27,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editing, false) |> assign(:editing, false)
|> assign(:editing_blocks, nil) |> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false) |> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new()) |> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false) |> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "") |> assign(:editor_picker_filter, "")
@ -141,11 +143,8 @@ defmodule BerrypodWeb.PageEditorHook do
{:ok, new_blocks, message} -> {:ok, new_blocks, message} ->
socket = socket =
socket socket
|> assign(:editing_blocks, new_blocks) |> apply_mutation(new_blocks, message, :content)
|> assign(:editor_dirty, true)
|> assign(:editor_show_picker, false) |> assign(:editor_show_picker, false)
|> assign(:editor_live_region_message, message)
|> reload_block_data(new_blocks)
{:halt, socket} {:halt, socket}
@ -160,13 +159,7 @@ defmodule BerrypodWeb.PageEditorHook do
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do
{:ok, new_blocks} -> {:ok, new_blocks} ->
socket = {:halt, apply_mutation(socket, new_blocks, "Settings updated", :content)}
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_dirty, true)
|> reload_block_data(new_blocks)
{:halt, socket}
:noop -> :noop ->
{:halt, socket} {:halt, socket}
@ -304,11 +297,9 @@ defmodule BerrypodWeb.PageEditorHook do
{:ok, new_blocks} -> {:ok, new_blocks} ->
socket = socket =
socket socket
|> assign(:editing_blocks, new_blocks) |> apply_mutation(new_blocks, "Image selected", :content)
|> assign(:editor_dirty, true)
|> assign(:editor_image_picker_block_id, nil) |> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil) |> assign(:editor_image_picker_field_key, nil)
|> reload_block_data(new_blocks)
{:halt, socket} {:halt, socket}
@ -326,15 +317,53 @@ defmodule BerrypodWeb.PageEditorHook do
field_key => "" field_key => ""
}) do }) do
{:ok, new_blocks} -> {:ok, new_blocks} ->
{:halt, apply_mutation(socket, new_blocks, "Image cleared", :content)}
:noop ->
{:halt, socket}
end
end
# ── Undo / redo ──────────────────────────────────────────────────
defp handle_editor_action("undo", _params, socket) do
case socket.assigns.editor_history do
[prev | rest] ->
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
socket = socket =
socket socket
|> assign(:editing_blocks, new_blocks) |> assign(:editing_blocks, prev)
|> assign(:editor_history, rest)
|> assign(:editor_future, future)
|> assign(:editor_dirty, true) |> assign(:editor_dirty, true)
|> reload_block_data(new_blocks) |> assign(:editor_live_region_message, "Undone")
|> reload_block_data(prev)
{:halt, socket} {:halt, socket}
:noop -> [] ->
{:halt, socket}
end
end
defp handle_editor_action("redo", _params, socket) do
case socket.assigns.editor_future do
[next | rest] ->
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
socket =
socket
|> assign(:editing_blocks, next)
|> assign(:editor_history, history)
|> assign(:editor_future, rest)
|> assign(:editor_dirty, true)
|> assign(:editor_live_region_message, "Redone")
|> reload_block_data(next)
{:halt, socket}
[] ->
{:halt, socket} {:halt, socket}
end end
end end
@ -353,6 +382,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:page, updated_page) |> assign(:page, updated_page)
|> assign(:editing_blocks, updated_page.blocks) |> assign(:editing_blocks, updated_page.blocks)
|> assign(:editor_dirty, false) |> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> put_flash(:info, "Page saved") |> put_flash(:info, "Page saved")
{:halt, socket} {:halt, socket}
@ -372,6 +403,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:page, page) |> assign(:page, page)
|> assign(:editing_blocks, page.blocks) |> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false) |> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> reload_block_data(page.blocks) |> reload_block_data(page.blocks)
|> put_flash(:info, "Page reset to defaults") |> put_flash(:info, "Page reset to defaults")
@ -396,6 +429,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editing, true) |> assign(:editing, true)
|> assign(:editing_blocks, page.blocks) |> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false) |> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new()) |> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false) |> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "") |> assign(:editor_picker_filter, "")
@ -413,6 +448,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editing, false) |> assign(:editing, false)
|> assign(:editing_blocks, nil) |> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false) |> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new()) |> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false) |> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "") |> assign(:editor_picker_filter, "")
@ -426,9 +463,14 @@ defmodule BerrypodWeb.PageEditorHook do
end end
defp apply_mutation(socket, new_blocks, message, type) do defp apply_mutation(socket, new_blocks, message, type) do
history =
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
socket = socket =
socket socket
|> assign(:editing_blocks, new_blocks) |> assign(:editing_blocks, new_blocks)
|> assign(:editor_history, history)
|> assign(:editor_future, [])
|> assign(:editor_dirty, true) |> assign(:editor_dirty, true)
|> assign(:editor_live_region_message, message) |> assign(:editor_live_region_message, message)

View File

@ -64,14 +64,37 @@ defmodule BerrypodWeb.PageRenderer do
<div <div
id="page-editor-live" id="page-editor-live"
class="page-editor-live" class="page-editor-live"
phx-hook="DirtyGuard" phx-hook="EditorKeyboard"
data-dirty={to_string(@editor_dirty)} data-dirty={to_string(@editor_dirty)}
data-event-prefix="editor_"
data-sidebar-open={to_string(@editor_sidebar_open)} data-sidebar-open={to_string(@editor_sidebar_open)}
> >
<aside class="page-editor-sidebar" aria-label="Page editor"> <aside class="page-editor-sidebar" aria-label="Page editor">
<div class="page-editor-sidebar-header"> <div class="page-editor-sidebar-header">
<h2 class="page-editor-sidebar-title">{@page.title}</h2> <h2 class="page-editor-sidebar-title">{@page.title}</h2>
<div class="page-editor-sidebar-actions"> <div class="page-editor-sidebar-actions">
<button
phx-click="editor_undo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_history == [] && "opacity-30"
]}
disabled={@editor_history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="editor_redo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_future == [] && "opacity-30"
]}
disabled={@editor_future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button <button
phx-click="editor_save" phx-click="editor_save"
class={[ class={[

View File

@ -267,6 +267,113 @@ defmodule BerrypodWeb.Admin.PagesTest do
end end
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 describe "page editor for page-specific pages" do
setup %{conn: conn, user: user} do setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)} %{conn: log_in_user(conn, user)}

View File

@ -215,6 +215,88 @@ defmodule BerrypodWeb.PageEditorHookTest do
end end
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 describe "content pages (deferred init)" do
setup %{conn: conn, user: user} do setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)} %{conn: log_in_user(conn, user)}