add inline block settings editing to page editor
All checks were successful
deploy / deploy (push) Successful in 3m40s
All checks were successful
deploy / deploy (push) Successful in 3m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
660fda928f
commit
3f97742c0b
12
PROGRESS.md
12
PROGRESS.md
@ -9,7 +9,7 @@
|
|||||||
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
||||||
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
|
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
|
||||||
- Mobile-first design with bottom navigation
|
- 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)
|
- SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit)
|
||||||
- Variant selector with color swatches and size buttons
|
- Variant selector with color swatches and size buttons
|
||||||
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
|
- 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
|
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan
|
||||||
|
|
||||||
### Page Editor
|
### 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).
|
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~~ ✅
|
3. ~~Wire simple pages — Home, Content (x4), Contact, Error~~ ✅
|
||||||
4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅
|
4. ~~Wire shop pages — Collection, PDP, Cart, Search~~ ✅
|
||||||
5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ ✅
|
5. ~~Wire order pages + theme preview — CheckoutSuccess, Orders, OrderDetail, theme editor~~ ✅
|
||||||
6. **Next →** Admin editor — page list + block management (reorder, add, remove, duplicate, save)
|
6. ~~Admin editor — page list + block management~~ ✅ (`660fda9`)
|
||||||
7. Admin editor — inline block settings editing
|
7. ~~Admin editor — inline block settings editing~~ ✅
|
||||||
8. Live preview — split layout with real-time preview
|
8. **Next →** Live preview — split layout with real-time preview
|
||||||
9. Undo/redo + polish — history stacks, keyboard shortcuts, animations
|
9. Undo/redo + polish — history stacks, keyboard shortcuts, 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)
|
||||||
- `lib/berrypod/pages/` — Page schema, BlockTypes (26 types), Defaults (14 pages), PageCache (ETS)
|
- `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/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/pages_test.exs` — 34 tests
|
||||||
- `test/berrypod_web/page_renderer_test.exs` — 18 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
|
See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for full plan
|
||||||
|
|
||||||
|
|||||||
@ -1163,9 +1163,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-card {
|
.block-card {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--t-border-default);
|
border: 1px solid var(--t-border-default);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@ -1318,4 +1315,47 @@
|
|||||||
font-size: 0.8125rem;
|
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 */
|
} /* @layer admin */
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Page builder plan
|
# Page builder plan
|
||||||
|
|
||||||
Status: In progress (Stage 5 complete)
|
Status: In progress (Stage 7 complete)
|
||||||
|
|
||||||
## Context
|
## 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
|
- [x] `Admin.Pages.Index` — page list with 5 groups (Marketing, Legal, Shop, Orders, System), icons, block counts
|
||||||
- [ ] Routes: `/admin/pages` and `/admin/pages/:slug`
|
- [x] `Admin.Pages.Editor` — block cards with position numbers, icons, names
|
||||||
- [ ] Add "Pages" nav link to admin sidebar
|
- [x] Routes: `/admin/pages` and `/admin/pages/:slug`, "Pages" nav link in admin sidebar
|
||||||
- [ ] Page list: grouped cards (Marketing, Legal, Shop, Order, System)
|
- [x] Move up/down with ARIA live region announcements, disabled at edges
|
||||||
- [ ] Block list: ordered cards with position number, icon, name
|
- [x] Remove block (with confirmation), duplicate block (copies settings)
|
||||||
- [ ] Move up/down buttons with accessible UX (focus follows, ARIA live region, disabled at edges)
|
- [x] "+ Add block" picker with search/filter, enforces `allowed_on` per page
|
||||||
- [ ] Remove block button
|
- [x] Save → persist to DB, invalidate cache, flash. Reset to defaults with confirmation
|
||||||
- [ ] "+ Add block" picker showing allowed blocks for this page, with search/filter
|
- [x] `@dirty` flag + DirtyGuard JS hook for unsaved changes warning
|
||||||
- [ ] Duplicate block button
|
- [x] Admin CSS for page list, block cards, block picker modal
|
||||||
- [ ] "Reset to defaults" with confirmation
|
- [x] 25 integration tests covering list, reorder, add, remove, duplicate, reset, save, dirty flag
|
||||||
- [ ] Save → persist to DB, invalidate cache, flash
|
- [x] Regenerated admin icons (81 rules) with `@layer admin` wrapping fix in mix task
|
||||||
- [ ] `@dirty` flag + DirtyGuard hook for unsaved changes warning
|
- [x] Added `:key` to renderer block loop for correct LiveView diffing
|
||||||
- [ ] Admin CSS for page editor
|
- [x] 1309 tests pass, `mix precommit` clean
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 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`
|
- [x] Inline settings form generated from `settings_schema` — text, textarea, select, number, json (read-only)
|
||||||
- [ ] Form field types: text, textarea, select, number, json
|
- [x] Block expand/collapse with `@expanded` MapSet, edit button (cog icon) on blocks with settings
|
||||||
- [ ] Cancel/Apply on each block's settings form
|
- [x] `phx-change` updates working state instantly, no Cancel/Apply (page-level Save/Reset handles it)
|
||||||
- [ ] Block collapse/expand (icon + name one-liner vs expanded card)
|
- [x] Number type coercion (form params → integers), schema defaults merged for missing keys
|
||||||
- [ ] Integration tests: edit hero title, save, verify on shop
|
- [x] Full ARIA: `aria-expanded`, `aria-controls`, live region announcements, unique field IDs
|
||||||
|
- [x] Debouncing: 300ms on text/textarea, blur on select/number
|
||||||
**Commit:** `add inline block settings editing to page editor`
|
- [x] 11 new tests (36 total in pages_test), 1320 tests total, `mix precommit` clean
|
||||||
|
|
||||||
**Verify:** `mix test` passes, edit home hero title in admin, save, see it on the shop
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|> assign(:dirty, false)
|
|> assign(:dirty, false)
|
||||||
|> assign(:show_picker, false)
|
|> assign(:show_picker, false)
|
||||||
|> assign(:picker_filter, "")
|
|> assign(:picker_filter, "")
|
||||||
|
|> assign(:expanded, MapSet.new())
|
||||||
|> assign(:live_region_message, nil)}
|
|> assign(:live_region_message, nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -109,6 +110,58 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
end
|
end
|
||||||
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
|
def handle_event("show_picker", _params, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
@ -225,6 +278,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
block={block}
|
block={block}
|
||||||
idx={idx}
|
idx={idx}
|
||||||
total={length(@blocks)}
|
total={length(@blocks)}
|
||||||
|
expanded={@expanded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div :if={@blocks == []} class="block-list-empty">
|
<div :if={@blocks == []} class="block-list-empty">
|
||||||
@ -251,15 +305,23 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|
|
||||||
defp block_card(assigns) do
|
defp block_card(assigns) do
|
||||||
block_type = BlockTypes.get(assigns.block["type"])
|
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"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
class="block-card"
|
class={["block-card", @is_expanded && "block-card-expanded"]}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-label={"#{@block_type && @block_type.name || @block["type"]}, position #{@idx + 1} of #{@total}"}
|
aria-label={"#{@block_type && @block_type.name || @block["type"]}, position #{@idx + 1} of #{@total}"}
|
||||||
id={"block-#{@block["id"]}"}
|
id={"block-#{@block["id"]}"}
|
||||||
>
|
>
|
||||||
|
<div class="block-card-header">
|
||||||
<span class="block-card-position">{@idx + 1}</span>
|
<span class="block-card-position">{@idx + 1}</span>
|
||||||
|
|
||||||
<span class="block-card-icon">
|
<span class="block-card-icon">
|
||||||
@ -271,6 +333,21 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="block-card-controls">
|
<span class="block-card-controls">
|
||||||
|
<button
|
||||||
|
:if={@has_settings}
|
||||||
|
phx-click="toggle_expand"
|
||||||
|
phx-value-id={@block["id"]}
|
||||||
|
class={[
|
||||||
|
"admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm",
|
||||||
|
@is_expanded && "block-edit-btn-active"
|
||||||
|
]}
|
||||||
|
aria-label={"Edit #{@block_type && @block_type.name} settings"}
|
||||||
|
aria-expanded={to_string(@is_expanded)}
|
||||||
|
aria-controls={"block-settings-#{@block["id"]}"}
|
||||||
|
id={"block-edit-btn-#{@block["id"]}"}
|
||||||
|
>
|
||||||
|
<.icon name="hero-cog-6-tooth-mini" class="size-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
phx-click="move_up"
|
phx-click="move_up"
|
||||||
phx-value-id={@block["id"]}
|
phx-value-id={@block["id"]}
|
||||||
@ -308,6 +385,134 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.block_settings_form
|
||||||
|
:if={@is_expanded}
|
||||||
|
block={@block}
|
||||||
|
schema={@block_type.settings_schema}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp block_settings_form(assigns) do
|
||||||
|
settings = settings_with_defaults(assigns.block)
|
||||||
|
assigns = assign(assigns, :settings, settings)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="block-card-settings" id={"block-settings-#{@block["id"]}"}>
|
||||||
|
<form phx-change="update_block_settings">
|
||||||
|
<input type="hidden" name="block_id" value={@block["id"]} />
|
||||||
|
|
||||||
|
<div class="block-settings-fields">
|
||||||
|
<.block_field
|
||||||
|
:for={field <- @schema}
|
||||||
|
field={field}
|
||||||
|
value={@settings[field.key]}
|
||||||
|
block_id={@block["id"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp block_field(%{field: %{type: :select}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-fieldset">
|
||||||
|
<label>
|
||||||
|
<span class="admin-label">{@field.label}</span>
|
||||||
|
<select
|
||||||
|
name={"block_settings[#{@field.key}]"}
|
||||||
|
id={"block-#{@block_id}-#{@field.key}"}
|
||||||
|
class="admin-select"
|
||||||
|
phx-debounce="blur"
|
||||||
|
>
|
||||||
|
{Phoenix.HTML.Form.options_for_select(@field.options, to_string(@value))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp block_field(%{field: %{type: :textarea}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-fieldset">
|
||||||
|
<label>
|
||||||
|
<span class="admin-label">{@field.label}</span>
|
||||||
|
<textarea
|
||||||
|
name={"block_settings[#{@field.key}]"}
|
||||||
|
id={"block-#{@block_id}-#{@field.key}"}
|
||||||
|
class="admin-textarea"
|
||||||
|
rows="3"
|
||||||
|
phx-debounce="300"
|
||||||
|
>{@value}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp block_field(%{field: %{type: :number}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-fieldset">
|
||||||
|
<label>
|
||||||
|
<span class="admin-label">{@field.label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={"block_settings[#{@field.key}]"}
|
||||||
|
id={"block-#{@block_id}-#{@field.key}"}
|
||||||
|
value={@value}
|
||||||
|
class="admin-input"
|
||||||
|
phx-debounce="blur"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div class="admin-fieldset">
|
||||||
|
<label>
|
||||||
|
<span class="admin-label">{@field.label}</span>
|
||||||
|
<textarea
|
||||||
|
name={"block_settings[#{@field.key}]"}
|
||||||
|
id={"block-#{@block_id}-#{@field.key}"}
|
||||||
|
class="admin-textarea block-settings-json"
|
||||||
|
rows="4"
|
||||||
|
readonly
|
||||||
|
aria-readonly="true"
|
||||||
|
>{@json_str}</textarea>
|
||||||
|
<p class="block-settings-hint">JSON data — editing coming soon</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp block_field(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="admin-fieldset">
|
||||||
|
<label>
|
||||||
|
<span class="admin-label">{@field.label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={"block_settings[#{@field.key}]"}
|
||||||
|
id={"block-#{@block_id}-#{@field.key}"}
|
||||||
|
value={@value}
|
||||||
|
class="admin-input"
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -375,4 +580,46 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
_ -> block["type"]
|
_ -> block["type"]
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -297,4 +297,185 @@ defmodule BerrypodWeb.Admin.PagesTest do
|
|||||||
assert has_element?(view, ".block-card-name", "Featured products")
|
assert has_element?(view, ".block-card-name", "Featured products")
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user