From 3f97742c0ba44f7934fdae9796b9d8ac443fafb3 Mon Sep 17 00:00:00 2001 From: jamey Date: Thu, 26 Feb 2026 21:47:24 +0000 Subject: [PATCH] add inline block settings editing to page editor Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 12 +- assets/css/admin/components.css | 46 ++- docs/plans/page-builder.md | 57 ++-- lib/berrypod_web/live/admin/pages/editor.ex | 337 +++++++++++++++++--- test/berrypod_web/live/admin/pages_test.exs | 181 +++++++++++ 5 files changed, 548 insertions(+), 85 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f95ade7..90036a5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -9,7 +9,7 @@ - Image optimization pipeline (AVIF/WebP/JPEG responsive variants) - Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms) - Mobile-first design with bottom navigation -- 1284 tests passing, 100% PageSpeed score +- 1309 tests passing, 100% PageSpeed score - SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit) - Variant selector with color swatches and size buttons - Session-based cart with real variant data (add/remove/quantity, cross-tab sync) @@ -458,7 +458,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan ### Page Editor -**Status:** In progress — Stage 5 of 9 complete, 1284 tests +**Status:** In progress — Stage 7 of 9 complete, 1320 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). @@ -468,17 +468,19 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 3. ~~Wire simple pages — Home, Content (x4), Contact, Error~~ ✅ 4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅ 5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ ✅ -6. **Next →** Admin editor — page list + block management (reorder, add, remove, duplicate, save) -7. Admin editor — inline block settings editing -8. Live preview — split layout with real-time preview +6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`) +7. ~~Admin editor — inline block settings editing~~ ✅ +8. **Next →** Live preview — split layout with real-time preview 9. Undo/redo + polish — history stacks, keyboard shortcuts, animations **Key files created:** - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) - `lib/berrypod/pages/` — Page schema, BlockTypes (26 types), Defaults (14 pages), PageCache (ETS) - `lib/berrypod_web/page_renderer.ex` — generic renderer dispatching blocks to existing shop components +- `lib/berrypod_web/live/admin/pages/` — Index (page list) + Editor (block management) - `test/berrypod/pages_test.exs` — 34 tests - `test/berrypod_web/page_renderer_test.exs` — 18 tests +- `test/berrypod_web/live/admin/pages_test.exs` — 36 tests See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 21419a9..7b10760 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1163,9 +1163,6 @@ } .block-card { - display: flex; - align-items: center; - gap: 0.5rem; padding: 0.5rem 0.75rem; border: 1px solid var(--t-border-default); border-radius: 0.5rem; @@ -1318,4 +1315,47 @@ font-size: 0.8125rem; } +/* Block card header + expanded state */ + +.block-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.block-card-expanded { + background: var(--t-surface-sunken); +} + +.block-edit-btn-active { + color: var(--t-accent); +} + +/* Block settings panel */ + +.block-card-settings { + padding: 0.75rem 0.75rem 0.25rem; + padding-left: 2.75rem; + border-top: 1px solid var(--t-border-default); +} + +.block-settings-fields { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.block-settings-json { + font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; + font-size: 0.75rem; + opacity: 0.7; +} + +.block-settings-hint { + font-size: 0.75rem; + color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); + margin-top: 0.25rem; +} + } /* @layer admin */ diff --git a/docs/plans/page-builder.md b/docs/plans/page-builder.md index 62105bb..c7bc426 100644 --- a/docs/plans/page-builder.md +++ b/docs/plans/page-builder.md @@ -1,6 +1,6 @@ # Page builder plan -Status: In progress (Stage 5 complete) +Status: In progress (Stage 7 complete) ## Context @@ -661,44 +661,37 @@ Each stage is a commit point. Tests pass, all pages work, nothing is broken. Pic --- -### Stage 6: Admin editor — page list + block management +### Stage 6: Admin editor — page list + block management ✅ -**Goal:** admin can see all pages and reorder/add/remove blocks. No inline editing yet. Save persists to DB. +**Status:** Complete (commit `660fda9`) -- [ ] Create `Admin.Pages` LiveView with `:index` and `:edit` live_actions -- [ ] Routes: `/admin/pages` and `/admin/pages/:slug` -- [ ] Add "Pages" nav link to admin sidebar -- [ ] Page list: grouped cards (Marketing, Legal, Shop, Order, System) -- [ ] Block list: ordered cards with position number, icon, name -- [ ] Move up/down buttons with accessible UX (focus follows, ARIA live region, disabled at edges) -- [ ] Remove block button -- [ ] "+ Add block" picker showing allowed blocks for this page, with search/filter -- [ ] Duplicate block button -- [ ] "Reset to defaults" with confirmation -- [ ] Save → persist to DB, invalidate cache, flash -- [ ] `@dirty` flag + DirtyGuard hook for unsaved changes warning -- [ ] Admin CSS for page editor -- [ ] Integration tests: list pages, reorder, add, remove, duplicate, reset, save - -**Commit:** `add admin page editor with block reordering and management` - -**Verify:** `mix test` passes, can reorder blocks on home page, save, refresh shop — layout changed +- [x] `Admin.Pages.Index` — page list with 5 groups (Marketing, Legal, Shop, Orders, System), icons, block counts +- [x] `Admin.Pages.Editor` — block cards with position numbers, icons, names +- [x] Routes: `/admin/pages` and `/admin/pages/:slug`, "Pages" nav link in admin sidebar +- [x] Move up/down with ARIA live region announcements, disabled at edges +- [x] Remove block (with confirmation), duplicate block (copies settings) +- [x] "+ Add block" picker with search/filter, enforces `allowed_on` per page +- [x] Save → persist to DB, invalidate cache, flash. Reset to defaults with confirmation +- [x] `@dirty` flag + DirtyGuard JS hook for unsaved changes warning +- [x] Admin CSS for page list, block cards, block picker modal +- [x] 25 integration tests covering list, reorder, add, remove, duplicate, reset, save, dirty flag +- [x] Regenerated admin icons (81 rules) with `@layer admin` wrapping fix in mix task +- [x] Added `:key` to renderer block loop for correct LiveView diffing +- [x] 1309 tests pass, `mix precommit` clean --- -### Stage 7: Admin editor — inline block editing +### Stage 7: Admin editor — inline block editing ✅ -**Goal:** admin can edit block settings (hero text, product count, etc). Full editing workflow complete. +**Status:** Complete -- [ ] Inline settings form generated from `settings_schema` -- [ ] Form field types: text, textarea, select, number, json -- [ ] Cancel/Apply on each block's settings form -- [ ] Block collapse/expand (icon + name one-liner vs expanded card) -- [ ] Integration tests: edit hero title, save, verify on shop - -**Commit:** `add inline block settings editing to page editor` - -**Verify:** `mix test` passes, edit home hero title in admin, save, see it on the shop +- [x] Inline settings form generated from `settings_schema` — text, textarea, select, number, json (read-only) +- [x] Block expand/collapse with `@expanded` MapSet, edit button (cog icon) on blocks with settings +- [x] `phx-change` updates working state instantly, no Cancel/Apply (page-level Save/Reset handles it) +- [x] Number type coercion (form params → integers), schema defaults merged for missing keys +- [x] Full ARIA: `aria-expanded`, `aria-controls`, live region announcements, unique field IDs +- [x] Debouncing: 300ms on text/textarea, blur on select/number +- [x] 11 new tests (36 total in pages_test), 1320 tests total, `mix precommit` clean --- diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 1756f05..31c975c 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -19,6 +19,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:dirty, false) |> assign(:show_picker, false) |> assign(:picker_filter, "") + |> assign(:expanded, MapSet.new()) |> assign(:live_region_message, nil)} end @@ -109,6 +110,58 @@ defmodule BerrypodWeb.Admin.Pages.Editor do end end + def handle_event("toggle_expand", %{"id" => block_id}, socket) do + expanded = socket.assigns.expanded + block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) + block_name = block_display_name(block) + + {new_expanded, action} = + if MapSet.member?(expanded, block_id) do + {MapSet.delete(expanded, block_id), "collapsed"} + else + {MapSet.put(expanded, block_id), "expanded"} + end + + {:noreply, + socket + |> assign(:expanded, new_expanded) + |> assign(:live_region_message, "#{block_name} settings #{action}")} + end + + def handle_event("update_block_settings", params, socket) do + block_id = params["block_id"] + new_settings = params["block_settings"] || %{} + + # Find the block and its schema to coerce types + block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) + + if block do + schema = + case BlockTypes.get(block["type"]) do + %{settings_schema: s} -> s + _ -> [] + end + + coerced = coerce_settings(new_settings, schema) + + new_blocks = + Enum.map(socket.assigns.blocks, fn b -> + if b["id"] == block_id do + Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced)) + else + b + end + end) + + {:noreply, + socket + |> assign(:blocks, new_blocks) + |> assign(:dirty, true)} + else + {:noreply, socket} + end + end + def handle_event("show_picker", _params, socket) do {:noreply, socket @@ -225,6 +278,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do block={block} idx={idx} total={length(@blocks)} + expanded={@expanded} />
@@ -251,62 +305,213 @@ defmodule BerrypodWeb.Admin.Pages.Editor do defp block_card(assigns) do block_type = BlockTypes.get(assigns.block["type"]) - assigns = assign(assigns, :block_type, block_type) + has_settings = has_settings?(assigns.block) + expanded = MapSet.member?(assigns.expanded, assigns.block["id"]) + + assigns = + assigns + |> assign(:block_type, block_type) + |> assign(:has_settings, has_settings) + |> assign(:is_expanded, expanded) ~H"""
- {@idx + 1} +
+ {@idx + 1} - - <.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" /> - + + <.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" /> + - - {(@block_type && @block_type.name) || @block["type"]} - + + {(@block_type && @block_type.name) || @block["type"]} + - - + + + + + +
+ + <.block_settings_form + :if={@is_expanded} + block={@block} + schema={@block_type.settings_schema} + /> +
+ """ + end + + defp block_settings_form(assigns) do + settings = settings_with_defaults(assigns.block) + assigns = assign(assigns, :settings, settings) + + ~H""" +
+
+ + +
+ <.block_field + :for={field <- @schema} + field={field} + value={@settings[field.key]} + block_id={@block["id"]} + /> +
+
+
+ """ + end + + defp block_field(%{field: %{type: :select}} = assigns) do + ~H""" +
+ +
+ """ + end + + defp block_field(%{field: %{type: :textarea}} = assigns) do + ~H""" +
+ +
+ """ + end + + defp block_field(%{field: %{type: :number}} = assigns) do + ~H""" +
+ +
+ """ + end + + defp block_field(%{field: %{type: :json}} = assigns) do + json_str = + case assigns.value do + val when is_list(val) or is_map(val) -> Jason.encode!(val, pretty: true) + val when is_binary(val) -> val + _ -> "[]" + end + + assigns = assign(assigns, :json_str, json_str) + + ~H""" +
+ +
+ """ + end + + defp block_field(assigns) do + ~H""" +
+
""" end @@ -375,4 +580,46 @@ defmodule BerrypodWeb.Admin.Pages.Editor do _ -> block["type"] end end + + defp coerce_settings(params, schema) do + type_map = Map.new(schema, fn field -> {field.key, field} end) + + Map.new(params, fn {key, value} -> + case type_map[key] do + %{type: :number, default: default} -> + {key, parse_number(value, default)} + + _ -> + {key, value} + end + end) + end + + defp parse_number(value, default) when is_binary(value) do + case Integer.parse(value) do + {n, ""} -> n + _ -> default + end + end + + defp parse_number(value, _default) when is_integer(value), do: value + defp parse_number(_value, default), do: default + + defp has_settings?(block) do + case BlockTypes.get(block["type"]) do + %{settings_schema: [_ | _]} -> true + _ -> false + end + end + + defp settings_with_defaults(block) do + schema = + case BlockTypes.get(block["type"]) do + %{settings_schema: s} -> s + _ -> [] + end + + defaults = Map.new(schema, fn field -> {field.key, field.default} end) + Map.merge(defaults, block["settings"] || %{}) + end end diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 98636b4..1ec932e 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -297,4 +297,185 @@ defmodule BerrypodWeb.Admin.PagesTest do assert has_element?(view, ".block-card-name", "Featured products") end end + + describe "block settings editing" do + setup %{conn: conn, user: user} do + %{conn: log_in_user(conn, user)} + end + + test "edit button shown only for blocks with settings", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + category_nav = Enum.at(page.blocks, 1) + + # Hero has settings — edit button present + assert has_element?(view, "#block-edit-btn-#{hero["id"]}") + + # Category nav has no settings — no edit button + refute has_element?(view, "#block-edit-btn-#{category_nav["id"]}") + end + + test "toggle expand shows settings form", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + + # Settings panel not visible initially + refute has_element?(view, "#block-settings-#{hero["id"]}") + + # Click edit to expand + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + + # Settings panel now visible + assert has_element?(view, "#block-settings-#{hero["id"]}") + assert has_element?(view, "[aria-live='polite']", "Hero banner settings expanded") + end + + test "toggle collapse hides settings form", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + + # Expand then collapse + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + assert has_element?(view, "#block-settings-#{hero["id"]}") + + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + refute has_element?(view, "#block-settings-#{hero["id"]}") + assert has_element?(view, "[aria-live='polite']", "Hero banner settings collapsed") + end + + test "aria-expanded reflects toggle state", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + + assert has_element?(view, "#block-edit-btn-#{hero["id"]}[aria-expanded='false']") + + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + + assert has_element?(view, "#block-edit-btn-#{hero["id"]}[aria-expanded='true']") + end + + test "settings form renders fields from schema", %{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, "toggle_expand", %{"id" => hero["id"]}) + + # Hero has text fields: title, description, cta_text, etc. + assert has_element?(view, "#block-#{hero["id"]}-title") + assert has_element?(view, "#block-#{hero["id"]}-description") + assert has_element?(view, "#block-#{hero["id"]}-cta_text") + # And a select field: variant + assert has_element?(view, "#block-#{hero["id"]}-variant") + end + + test "editing settings updates working state and sets dirty", %{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, "toggle_expand", %{"id" => hero["id"]}) + + # Edit the title + render_change(view, "update_block_settings", %{ + "block_id" => hero["id"], + "block_settings" => %{"title" => "New hero title"} + }) + + # Dirty flag should appear + assert has_element?(view, ".admin-badge-warning", "Unsaved changes") + end + + test "edited settings persist after save", %{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, "toggle_expand", %{"id" => hero["id"]}) + + render_change(view, "update_block_settings", %{ + "block_id" => hero["id"], + "block_settings" => %{"title" => "Updated title", "description" => "Updated desc"} + }) + + render_click(view, "save") + + saved = Pages.get_page("home") + saved_hero = Enum.find(saved.blocks, &(&1["type"] == "hero")) + assert saved_hero["settings"]["title"] == "Updated title" + assert saved_hero["settings"]["description"] == "Updated desc" + end + + test "number fields are coerced to integers", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + featured = Enum.find(page.blocks, &(&1["type"] == "featured_products")) + + render_click(view, "toggle_expand", %{"id" => featured["id"]}) + + render_change(view, "update_block_settings", %{ + "block_id" => featured["id"], + "block_settings" => %{"product_count" => "4"} + }) + + render_click(view, "save") + + saved = Pages.get_page("home") + saved_featured = Enum.find(saved.blocks, &(&1["type"] == "featured_products")) + assert saved_featured["settings"]["product_count"] == 4 + end + + test "select fields render with options", %{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, "toggle_expand", %{"id" => hero["id"]}) + + html = render(view) + # Variant select should have the expected options + assert html =~ "default" + assert html =~ "sunken" + end + + test "multiple blocks can be expanded simultaneously", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + featured = Enum.find(page.blocks, &(&1["type"] == "featured_products")) + + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + render_click(view, "toggle_expand", %{"id" => featured["id"]}) + + assert has_element?(view, "#block-settings-#{hero["id"]}") + assert has_element?(view, "#block-settings-#{featured["id"]}") + end + + test "expanded card has expanded class", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/pages/home") + + page = Pages.get_page("home") + hero = Enum.at(page.blocks, 0) + + refute has_element?(view, "#block-#{hero["id"]}.block-card-expanded") + + render_click(view, "toggle_expand", %{"id" => hero["id"]}) + + assert has_element?(view, "#block-#{hero["id"]}.block-card-expanded") + end + end end