add live page editor sidebar with collapsible UI
All checks were successful
deploy / deploy (push) Successful in 6m49s
All checks were successful
deploy / deploy (push) Successful in 6m49s
Admins can now edit pages directly on the live shop by clicking the pencil icon in the header. A sidebar slides in with block management controls (add, remove, reorder, edit settings, save, reset, done). Key features: - PageEditorHook on_mount with handle_params/event/info hooks - BlockEditor pure functions extracted from admin editor - Shared BlockEditorComponents with event_prefix namespacing - Collapsible sidebar: X closes it, header pencil reopens it - Backdrop overlay dismisses sidebar on tap - Conditional admin.css loading for logged-in users - content_body block now portable (textarea setting + rich text fallback) 13 integration tests, 26 unit tests, 1370 total passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
187
test/berrypod/pages/block_editor_test.exs
Normal file
187
test/berrypod/pages/block_editor_test.exs
Normal file
@@ -0,0 +1,187 @@
|
||||
defmodule Berrypod.Pages.BlockEditorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Berrypod.Pages.BlockEditor
|
||||
|
||||
defp make_block(id, type, settings \\ %{}) do
|
||||
%{"id" => id, "type" => type, "settings" => settings}
|
||||
end
|
||||
|
||||
defp three_blocks do
|
||||
[
|
||||
make_block("a", "hero", %{"title" => "Welcome"}),
|
||||
make_block("b", "featured_products"),
|
||||
make_block("c", "image_text", %{"title" => "About us"})
|
||||
]
|
||||
end
|
||||
|
||||
describe "move_up/2" do
|
||||
test "moves a block up one position" do
|
||||
{:ok, blocks, msg} = BlockEditor.move_up(three_blocks(), "b")
|
||||
|
||||
assert [%{"id" => "b"}, %{"id" => "a"}, %{"id" => "c"}] = blocks
|
||||
assert msg =~ "moved to position"
|
||||
end
|
||||
|
||||
test "returns :noop for the first block" do
|
||||
assert :noop = BlockEditor.move_up(three_blocks(), "a")
|
||||
end
|
||||
|
||||
test "returns :noop for unknown id" do
|
||||
assert :noop = BlockEditor.move_up(three_blocks(), "zzz")
|
||||
end
|
||||
end
|
||||
|
||||
describe "move_down/2" do
|
||||
test "moves a block down one position" do
|
||||
{:ok, blocks, msg} = BlockEditor.move_down(three_blocks(), "b")
|
||||
|
||||
assert [%{"id" => "a"}, %{"id" => "c"}, %{"id" => "b"}] = blocks
|
||||
assert msg =~ "moved to position"
|
||||
end
|
||||
|
||||
test "returns :noop for the last block" do
|
||||
assert :noop = BlockEditor.move_down(three_blocks(), "c")
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_block/2" do
|
||||
test "removes the specified block" do
|
||||
{:ok, blocks, msg} = BlockEditor.remove_block(three_blocks(), "b")
|
||||
|
||||
assert length(blocks) == 2
|
||||
refute Enum.any?(blocks, &(&1["id"] == "b"))
|
||||
assert msg =~ "removed"
|
||||
end
|
||||
end
|
||||
|
||||
describe "duplicate_block/2" do
|
||||
test "inserts a copy after the original" do
|
||||
{:ok, blocks, msg} = BlockEditor.duplicate_block(three_blocks(), "a")
|
||||
|
||||
assert length(blocks) == 4
|
||||
assert Enum.at(blocks, 0)["id"] == "a"
|
||||
# Copy is at index 1 with a new ID
|
||||
copy = Enum.at(blocks, 1)
|
||||
assert copy["type"] == "hero"
|
||||
assert copy["id"] != "a"
|
||||
assert copy["settings"] == %{"title" => "Welcome"}
|
||||
assert msg =~ "duplicated"
|
||||
end
|
||||
|
||||
test "returns :noop for unknown id" do
|
||||
assert :noop = BlockEditor.duplicate_block(three_blocks(), "zzz")
|
||||
end
|
||||
end
|
||||
|
||||
describe "add_block/2" do
|
||||
test "appends a new block of the given type" do
|
||||
{:ok, blocks, msg} = BlockEditor.add_block(three_blocks(), "hero")
|
||||
|
||||
assert length(blocks) == 4
|
||||
new = List.last(blocks)
|
||||
assert new["type"] == "hero"
|
||||
assert is_binary(new["id"])
|
||||
assert msg =~ "added"
|
||||
end
|
||||
|
||||
test "returns :noop for unknown block type" do
|
||||
assert :noop = BlockEditor.add_block(three_blocks(), "nonexistent_block_type")
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_settings/3" do
|
||||
test "merges new settings into the block" do
|
||||
{:ok, blocks} =
|
||||
BlockEditor.update_settings(three_blocks(), "a", %{"title" => "New title"})
|
||||
|
||||
hero = Enum.find(blocks, &(&1["id"] == "a"))
|
||||
assert hero["settings"]["title"] == "New title"
|
||||
end
|
||||
|
||||
test "returns :noop for unknown block id" do
|
||||
assert :noop = BlockEditor.update_settings(three_blocks(), "zzz", %{"title" => "Nope"})
|
||||
end
|
||||
end
|
||||
|
||||
describe "block_display_name/1" do
|
||||
test "returns the block type name from registry" do
|
||||
block = make_block("a", "hero")
|
||||
assert BlockEditor.block_display_name(block) == "Hero banner"
|
||||
end
|
||||
|
||||
test "returns the raw type for unknown types" do
|
||||
block = make_block("a", "weird_thing")
|
||||
assert BlockEditor.block_display_name(block) == "weird_thing"
|
||||
end
|
||||
|
||||
test "handles nil" do
|
||||
assert BlockEditor.block_display_name(nil) == "Block"
|
||||
end
|
||||
end
|
||||
|
||||
describe "has_settings?/1" do
|
||||
test "returns true for blocks with settings schema" do
|
||||
assert BlockEditor.has_settings?(make_block("a", "hero"))
|
||||
end
|
||||
|
||||
test "returns false for blocks without settings" do
|
||||
refute BlockEditor.has_settings?(make_block("a", "category_nav"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "settings_with_defaults/1" do
|
||||
test "fills in default values for missing settings" do
|
||||
block = make_block("a", "hero", %{})
|
||||
result = BlockEditor.settings_with_defaults(block)
|
||||
|
||||
# Hero has a title field with a default
|
||||
assert is_binary(result["title"])
|
||||
end
|
||||
|
||||
test "preserves existing settings" do
|
||||
block = make_block("a", "hero", %{"title" => "Custom"})
|
||||
result = BlockEditor.settings_with_defaults(block)
|
||||
|
||||
assert result["title"] == "Custom"
|
||||
end
|
||||
end
|
||||
|
||||
describe "coerce_settings/2" do
|
||||
test "passes through string values" do
|
||||
schema = [%{key: "title", type: :text, default: ""}]
|
||||
result = BlockEditor.coerce_settings(%{"title" => "Hello"}, schema)
|
||||
assert result["title"] == "Hello"
|
||||
end
|
||||
|
||||
test "parses number fields" do
|
||||
schema = [%{key: "columns", type: :number, default: 4}]
|
||||
result = BlockEditor.coerce_settings(%{"columns" => "3"}, schema)
|
||||
assert result["columns"] == 3
|
||||
end
|
||||
|
||||
test "falls back to default for invalid numbers" do
|
||||
schema = [%{key: "columns", type: :number, default: 4}]
|
||||
result = BlockEditor.coerce_settings(%{"columns" => "abc"}, schema)
|
||||
assert result["columns"] == 4
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_number/2" do
|
||||
test "parses valid integer strings" do
|
||||
assert BlockEditor.parse_number("42", 0) == 42
|
||||
end
|
||||
|
||||
test "returns integer values as-is" do
|
||||
assert BlockEditor.parse_number(7, 0) == 7
|
||||
end
|
||||
|
||||
test "returns default for non-numeric strings" do
|
||||
assert BlockEditor.parse_number("nope", 5) == 5
|
||||
end
|
||||
|
||||
test "returns default for nil" do
|
||||
assert BlockEditor.parse_number(nil, 10) == 10
|
||||
end
|
||||
end
|
||||
end
|
||||
231
test/berrypod_web/page_editor_hook_test.exs
Normal file
231
test/berrypod_web/page_editor_hook_test.exs
Normal file
@@ -0,0 +1,231 @@
|
||||
defmodule BerrypodWeb.PageEditorHookTest do
|
||||
use BerrypodWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
import Berrypod.ProductsFixtures
|
||||
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.PageCache
|
||||
|
||||
setup do
|
||||
PageCache.invalidate_all()
|
||||
user = user_fixture()
|
||||
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||
|
||||
provider_conn = provider_connection_fixture()
|
||||
|
||||
product =
|
||||
product_fixture(%{
|
||||
provider_connection: provider_conn,
|
||||
title: "Test Product",
|
||||
category: "Test Category"
|
||||
})
|
||||
|
||||
product_variant_fixture(%{product: product, title: "Standard", price: 1999})
|
||||
Berrypod.Products.recompute_cached_fields(product)
|
||||
|
||||
%{user: user, product: product}
|
||||
end
|
||||
|
||||
describe "non-admin cannot access edit mode" do
|
||||
test "visiting with ?edit=true shows normal page, no sidebar", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/?edit=true")
|
||||
|
||||
refute html =~ "page-editor-sidebar"
|
||||
refute html =~ "page-editor-live"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit button visibility" do
|
||||
test "admin sees edit pencil in shop header", %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, _view, html} = live(conn, "/")
|
||||
|
||||
assert html =~ "Edit page"
|
||||
end
|
||||
|
||||
test "non-admin does not see edit pencil", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/")
|
||||
|
||||
refute html =~ "Edit page"
|
||||
end
|
||||
end
|
||||
|
||||
describe "entering and exiting edit mode" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "admin enters edit mode with ?edit=true", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
assert has_element?(view, ".page-editor-sidebar")
|
||||
assert has_element?(view, ".page-editor-content")
|
||||
assert has_element?(view, ".block-card")
|
||||
end
|
||||
|
||||
test "sidebar shows the page title", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
assert has_element?(view, ".page-editor-sidebar-title", "Home page")
|
||||
end
|
||||
|
||||
test "done button exits edit mode", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
# editor_done uses push_navigate, which causes a redirect
|
||||
{:error, {:live_redirect, %{to: "/"}}} =
|
||||
view |> element("button[phx-click='editor_done']") |> render_click()
|
||||
end
|
||||
|
||||
test "toggle sidebar hides and shows via header pencil", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
# Sidebar starts open, pencil button in header is hidden
|
||||
assert has_element?(view, "[data-sidebar-open='true']")
|
||||
|
||||
refute has_element?(
|
||||
view,
|
||||
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
|
||||
)
|
||||
|
||||
# Close the sidebar via the X button
|
||||
view
|
||||
|> element("button[phx-click='editor_toggle_sidebar'][aria-label='Close sidebar']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "[data-sidebar-open='false']")
|
||||
# Pencil button appears in header to re-open
|
||||
assert has_element?(
|
||||
view,
|
||||
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
|
||||
)
|
||||
|
||||
# Re-open via pencil in header
|
||||
view
|
||||
|> element("button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "[data-sidebar-open='true']")
|
||||
end
|
||||
|
||||
test "clicking backdrop hides the sidebar", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
# Backdrop present when sidebar is open
|
||||
assert has_element?(view, ".page-editor-backdrop")
|
||||
|
||||
# Click backdrop to dismiss
|
||||
view |> element(".page-editor-backdrop") |> render_click()
|
||||
|
||||
assert has_element?(view, "[data-sidebar-open='false']")
|
||||
# Backdrop gone when sidebar is hidden
|
||||
refute has_element?(view, ".page-editor-backdrop")
|
||||
end
|
||||
end
|
||||
|
||||
describe "block manipulation in edit mode" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "move block down reorders the list", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
# Home page default: hero is first block
|
||||
first_card = view |> element(".block-card:first-child")
|
||||
first_html = render(first_card)
|
||||
assert first_html =~ "Hero"
|
||||
|
||||
# Get the hero block's ID and move it down
|
||||
blocks = Pages.get_page("home").blocks
|
||||
hero_id = List.first(blocks)["id"]
|
||||
|
||||
view
|
||||
|> element("button[phx-click='editor_move_down'][phx-value-id='#{hero_id}']")
|
||||
|> render_click()
|
||||
|
||||
# After move, hero is no longer the first card
|
||||
updated_first = view |> element(".block-card:first-child") |> render()
|
||||
refute updated_first =~ "Hero"
|
||||
end
|
||||
|
||||
test "dirty indicator appears after changes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
refute has_element?(view, ".admin-badge-warning", "Unsaved changes")
|
||||
|
||||
# Move a block to trigger dirty state
|
||||
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()
|
||||
|
||||
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
|
||||
end
|
||||
end
|
||||
|
||||
describe "save and reset" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "save persists block changes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
# Move a block to make changes
|
||||
blocks = Pages.get_page("home").blocks
|
||||
original_first_type = List.first(blocks)["type"]
|
||||
second_id = Enum.at(blocks, 1)["id"]
|
||||
|
||||
view
|
||||
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|
||||
|> render_click()
|
||||
|
||||
# Save
|
||||
view |> element("button[phx-click='editor_save']") |> render_click()
|
||||
|
||||
assert has_element?(view, "#shop-flash-info", "Page saved")
|
||||
|
||||
# Verify persistence
|
||||
updated = Pages.get_page("home")
|
||||
refute List.first(updated.blocks)["type"] == original_first_type
|
||||
end
|
||||
|
||||
test "reset restores default blocks", %{conn: conn} do
|
||||
# First, save a modified page
|
||||
original = Pages.get_page("home")
|
||||
reordered = Enum.reverse(original.blocks)
|
||||
Pages.save_page("home", %{title: original.title, blocks: reordered})
|
||||
PageCache.invalidate_all()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
||||
|
||||
# Reset
|
||||
view |> element("button[phx-click='editor_reset_defaults']") |> render_click()
|
||||
|
||||
assert has_element?(view, "#shop-flash-info", "Page reset to defaults")
|
||||
|
||||
# Verify the blocks are back to defaults
|
||||
reset_page = Pages.get_page("home")
|
||||
assert List.first(reset_page.blocks)["type"] == List.first(original.blocks)["type"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "content pages (deferred init)" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "editing works on about page via deferred init", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/about?edit=true")
|
||||
|
||||
assert has_element?(view, ".page-editor-sidebar")
|
||||
assert has_element?(view, ".page-editor-sidebar-title", "About")
|
||||
assert has_element?(view, ".block-card")
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user