add SettingsField struct and repeater field type for block settings
All checks were successful
deploy / deploy (push) Successful in 1m23s

Introduces typed settings schema with SettingsField struct, replaces
the read-only JSON textarea with a full repeater UI for info_card items.
Supports add, remove, reorder and inline editing of repeater items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-27 00:54:13 +00:00
parent 3f97742c0b
commit 6fbd654d57
5 changed files with 470 additions and 52 deletions

View File

@ -1346,16 +1346,55 @@
gap: 0.625rem; gap: 0.625rem;
} }
.block-settings-json { /* Repeater fields */
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
font-size: 0.75rem; .repeater-field {
opacity: 0.7; display: flex;
flex-direction: column;
gap: 0.5rem;
} }
.block-settings-hint { .repeater-items {
font-size: 0.75rem; list-style: none;
color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); padding: 0;
margin-top: 0.25rem; margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.repeater-item {
border: 1px solid var(--t-border-default);
border-radius: 0.375rem;
padding: 0.5rem;
& fieldset {
border: none;
padding: 0;
margin: 0;
}
}
.repeater-item-fields {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.repeater-item-controls {
display: flex;
gap: 0.125rem;
justify-content: flex-end;
margin-top: 0.375rem;
}
.repeater-remove-btn {
color: var(--t-danger);
}
.repeater-add-btn {
align-self: flex-start;
border-style: dashed;
} }
} /* @layer admin */ } /* @layer admin */

View File

@ -7,6 +7,7 @@ defmodule Berrypod.Pages.BlockTypes do
is present on a page. is present on a page.
""" """
alias Berrypod.Pages.SettingsField
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Theme.PreviewData alias Berrypod.Theme.PreviewData
@ -18,13 +19,23 @@ defmodule Berrypod.Pages.BlockTypes do
icon: "hero-megaphone", icon: "hero-megaphone",
allowed_on: :all, allowed_on: :all,
settings_schema: [ settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""}, %SettingsField{key: "title", label: "Title", type: :text, default: ""},
%{key: "description", label: "Description", type: :textarea, default: ""}, %SettingsField{key: "description", label: "Description", type: :textarea, default: ""},
%{key: "cta_text", label: "Button text", type: :text, default: ""}, %SettingsField{key: "cta_text", label: "Button text", type: :text, default: ""},
%{key: "cta_href", label: "Button link", type: :text, default: ""}, %SettingsField{key: "cta_href", label: "Button link", type: :text, default: ""},
%{key: "secondary_cta_text", label: "Secondary button text", type: :text, default: ""}, %SettingsField{
%{key: "secondary_cta_href", label: "Secondary button link", type: :text, default: ""}, key: "secondary_cta_text",
%{ label: "Secondary button text",
type: :text,
default: ""
},
%SettingsField{
key: "secondary_cta_href",
label: "Secondary button link",
type: :text,
default: ""
},
%SettingsField{
key: "variant", key: "variant",
label: "Style", label: "Style",
type: :select, type: :select,
@ -38,23 +49,33 @@ defmodule Berrypod.Pages.BlockTypes do
icon: "hero-star", icon: "hero-star",
allowed_on: :all, allowed_on: :all,
settings_schema: [ settings_schema: [
%{key: "title", label: "Title", type: :text, default: "Featured products"}, %SettingsField{
%{key: "product_count", label: "Number of products", type: :number, default: 8}, key: "title",
%{ label: "Title",
type: :text,
default: "Featured products"
},
%SettingsField{
key: "product_count",
label: "Number of products",
type: :number,
default: 8
},
%SettingsField{
key: "layout", key: "layout",
label: "Layout", label: "Layout",
type: :select, type: :select,
options: ~w(section grid), options: ~w(section grid),
default: "section" default: "section"
}, },
%{ %SettingsField{
key: "card_variant", key: "card_variant",
label: "Card style", label: "Card style",
type: :select, type: :select,
options: ~w(featured default minimal compact), options: ~w(featured default minimal compact),
default: "featured" default: "featured"
}, },
%{ %SettingsField{
key: "columns", key: "columns",
label: "Columns", label: "Columns",
type: :select, type: :select,
@ -69,11 +90,11 @@ defmodule Berrypod.Pages.BlockTypes do
icon: "hero-photo", icon: "hero-photo",
allowed_on: :all, allowed_on: :all,
settings_schema: [ settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""}, %SettingsField{key: "title", label: "Title", type: :text, default: ""},
%{key: "description", label: "Description", type: :textarea, default: ""}, %SettingsField{key: "description", label: "Description", type: :textarea, default: ""},
%{key: "image_url", label: "Image URL", type: :text, default: ""}, %SettingsField{key: "image_url", label: "Image URL", type: :text, default: ""},
%{key: "link_text", label: "Link text", type: :text, default: ""}, %SettingsField{key: "link_text", label: "Link text", type: :text, default: ""},
%{key: "link_href", label: "Link URL", type: :text, default: ""} %SettingsField{key: "link_href", label: "Link URL", type: :text, default: ""}
] ]
}, },
"category_nav" => %{ "category_nav" => %{
@ -99,8 +120,17 @@ defmodule Berrypod.Pages.BlockTypes do
icon: "hero-information-circle", icon: "hero-information-circle",
allowed_on: :all, allowed_on: :all,
settings_schema: [ settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""}, %SettingsField{key: "title", label: "Title", type: :text, default: ""},
%{key: "items", label: "Items", type: :json, default: []} %SettingsField{
key: "items",
label: "Items",
type: :repeater,
default: [],
item_schema: [
%SettingsField{key: "label", label: "Label", type: :text, default: ""},
%SettingsField{key: "value", label: "Value", type: :text, default: ""}
]
}
] ]
}, },
"trust_badges" => %{ "trust_badges" => %{
@ -188,7 +218,7 @@ defmodule Berrypod.Pages.BlockTypes do
icon: "hero-envelope", icon: "hero-envelope",
allowed_on: ["contact"], allowed_on: ["contact"],
settings_schema: [ settings_schema: [
%{key: "email", label: "Email", type: :text, default: "hello@example.com"} %SettingsField{key: "email", label: "Email", type: :text, default: "hello@example.com"}
] ]
}, },
"order_tracking_card" => %{ "order_tracking_card" => %{
@ -205,8 +235,8 @@ defmodule Berrypod.Pages.BlockTypes do
icon: "hero-document-text", icon: "hero-document-text",
allowed_on: ["about", "delivery", "privacy", "terms"], allowed_on: ["about", "delivery", "privacy", "terms"],
settings_schema: [ settings_schema: [
%{key: "image_src", label: "Image", type: :text, default: ""}, %SettingsField{key: "image_src", label: "Image", type: :text, default: ""},
%{key: "image_alt", label: "Image alt text", type: :text, default: ""} %SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""}
] ]
}, },

View File

@ -0,0 +1,16 @@
defmodule Berrypod.Pages.SettingsField do
@moduledoc "Defines a single field in a block's settings schema."
@type field_type :: :text | :textarea | :number | :select | :repeater
@type t :: %__MODULE__{
key: String.t(),
label: String.t(),
type: field_type(),
default: term(),
options: [String.t()] | nil,
item_schema: [t()] | nil
}
@enforce_keys [:key, :label, :type]
defstruct [:key, :label, :type, default: nil, options: nil, item_schema: nil]
end

View File

@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.Pages alias Berrypod.Pages
alias Berrypod.Pages.{BlockTypes, Defaults} alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField}
@impl true @impl true
def mount(%{"slug" => slug}, _session, socket) do def mount(%{"slug" => slug}, _session, socket) do
@ -162,6 +162,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end end
end end
def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
if block do
schema = BlockTypes.get(block["type"])
field = Enum.find(schema.settings_schema, &(&1.key == field_key))
empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end)
items = (block["settings"] || %{})[field_key] || []
new_items = items ++ [empty_item]
new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, "Item added")}
else
{:noreply, socket}
end
end
def handle_event(
"repeater_remove",
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
socket
) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
if block do
index = String.to_integer(index_str)
items = (block["settings"] || %{})[field_key] || []
new_items = List.delete_at(items, index)
new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, "Item removed")}
else
{:noreply, socket}
end
end
def handle_event(
"repeater_move",
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
socket
) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
if block do
index = String.to_integer(index_str)
items = (block["settings"] || %{})[field_key] || []
target = if dir == "up", do: index - 1, else: index + 1
if target >= 0 and target < length(items) do
item = Enum.at(items, index)
new_items =
items
|> List.delete_at(index)
|> List.insert_at(target, item)
new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, "Item moved #{dir}")}
else
{:noreply, socket}
end
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
@ -470,34 +552,114 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
""" """
end end
defp block_field(%{field: %{type: :json}} = assigns) do defp block_field(%{field: %{type: :repeater}} = assigns) do
json_str = items = if is_list(assigns.value), do: assigns.value, else: []
case assigns.value do item_count = length(items)
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) assigns =
assigns
|> assign(:items, items)
|> assign(:item_count, item_count)
~H""" ~H"""
<div class="admin-fieldset"> <div class="repeater-field">
<label> <span class="admin-label">{@field.label}</span>
<span class="admin-label">{@field.label}</span>
<textarea <ol class="repeater-items" aria-label={@field.label}>
name={"block_settings[#{@field.key}]"} <li :for={{item, idx} <- Enum.with_index(@items)} class="repeater-item">
id={"block-#{@block_id}-#{@field.key}"} <fieldset>
class="admin-textarea block-settings-json" <legend class="sr-only">
rows="4" Item {idx + 1}: {item["label"] || item[:label] || "New item"}
readonly </legend>
aria-readonly="true"
>{@json_str}</textarea> <div class="repeater-item-fields">
<p class="block-settings-hint">JSON data editing coming soon</p> <.repeater_item_field
</label> :for={sub_field <- @field.item_schema}
sub_field={sub_field}
item={item}
field_key={@field.key}
block_id={@block_id}
index={idx}
/>
</div>
<div class="repeater-item-controls">
<button
type="button"
phx-click="repeater_move"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
phx-value-index={idx}
phx-value-dir="up"
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-xs"
aria-label={"Move item #{idx + 1} up"}
disabled={idx == 0}
>
<.icon name="hero-chevron-up-mini" class="size-3.5" />
</button>
<button
type="button"
phx-click="repeater_move"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
phx-value-index={idx}
phx-value-dir="down"
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-xs"
aria-label={"Move item #{idx + 1} down"}
disabled={idx == @item_count - 1}
>
<.icon name="hero-chevron-down-mini" class="size-3.5" />
</button>
<button
type="button"
phx-click="repeater_remove"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
phx-value-index={idx}
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-xs repeater-remove-btn"
aria-label={"Remove item #{idx + 1}"}
>
<.icon name="hero-x-mark-mini" class="size-3.5" />
</button>
</div>
</fieldset>
</li>
</ol>
<button
type="button"
phx-click="repeater_add"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
class="admin-btn admin-btn-outline admin-btn-xs repeater-add-btn"
>
<.icon name="hero-plus-mini" class="size-3.5" /> Add item
</button>
</div> </div>
""" """
end end
defp repeater_item_field(assigns) do
value = assigns.item[assigns.sub_field.key] || ""
assigns = assign(assigns, :value, value)
~H"""
<label class="repeater-sub-field">
<span class="sr-only">{@sub_field.label}</span>
<input
type="text"
name={"block_settings[#{@field_key}][#{@index}][#{@sub_field.key}]"}
id={"block-#{@block_id}-#{@field_key}-#{@index}-#{@sub_field.key}"}
value={@value}
placeholder={@sub_field.label}
class="admin-input admin-input-sm"
phx-debounce="300"
/>
</label>
"""
end
defp block_field(assigns) do defp block_field(assigns) do
~H""" ~H"""
<div class="admin-fieldset"> <div class="admin-fieldset">
@ -589,12 +751,34 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
%{type: :number, default: default} -> %{type: :number, default: default} ->
{key, parse_number(value, default)} {key, parse_number(value, default)}
%{type: :repeater} ->
{key, indexed_map_to_list(value)}
_ -> _ ->
{key, value} {key, value}
end end
end) end)
end end
defp indexed_map_to_list(map) when is_map(map) do
map
|> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end)
|> Enum.map(fn {_k, v} -> v end)
end
defp indexed_map_to_list(value), do: value
defp update_block_field(blocks, block_id, field_key, new_value) do
Enum.map(blocks, fn b ->
if b["id"] == block_id do
settings = Map.put(b["settings"] || %{}, field_key, new_value)
Map.put(b, "settings", settings)
else
b
end
end)
end
defp parse_number(value, default) when is_binary(value) do defp parse_number(value, default) when is_binary(value) do
case Integer.parse(value) do case Integer.parse(value) do
{n, ""} -> n {n, ""} -> n

View File

@ -478,4 +478,153 @@ defmodule BerrypodWeb.Admin.PagesTest do
assert has_element?(view, "#block-#{hero["id"]}.block-card-expanded") assert has_element?(view, "#block-#{hero["id"]}.block-card-expanded")
end end
end end
describe "repeater fields" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "repeater items render with input fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/contact")
page = Pages.get_page("contact")
info_card = Enum.find(page.blocks, &(&1["type"] == "info_card"))
render_click(view, "toggle_expand", %{"id" => info_card["id"]})
# Should render 3 default items with label/value inputs
html = render(view)
assert html =~ "Printing"
assert html =~ "Delivery"
assert html =~ "Issues"
end
test "editing a repeater item field sets dirty flag", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/contact")
page = Pages.get_page("contact")
info_card = Enum.find(page.blocks, &(&1["type"] == "info_card"))
render_click(view, "toggle_expand", %{"id" => info_card["id"]})
render_change(view, "update_block_settings", %{
"block_id" => info_card["id"],
"block_settings" => %{
"title" => "Handy to know",
"items" => %{
"0" => %{"label" => "Shipping", "value" => "1-3 business days"},
"1" => %{
"label" => "Delivery",
"value" => "Example: 3-7 business days after printing"
},
"2" => %{"label" => "Issues", "value" => "Example: Reprints for any defects"}
}
}
})
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
end
test "adding a repeater item appends an empty item", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/contact")
page = Pages.get_page("contact")
info_card = Enum.find(page.blocks, &(&1["type"] == "info_card"))
render_click(view, "toggle_expand", %{"id" => info_card["id"]})
# Should have 3 items initially
assert render(view) |> count_repeater_items() == 3
render_click(view, "repeater_add", %{
"block-id" => info_card["id"],
"field" => "items"
})
# Now 4 items
assert render(view) |> count_repeater_items() == 4
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
end
test "removing a repeater item removes it from the list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/contact")
page = Pages.get_page("contact")
info_card = Enum.find(page.blocks, &(&1["type"] == "info_card"))
render_click(view, "toggle_expand", %{"id" => info_card["id"]})
render_click(view, "repeater_remove", %{
"block-id" => info_card["id"],
"field" => "items",
"index" => "0"
})
html = render(view)
assert count_repeater_items(html) == 2
# "Printing" was item 0, should be gone
refute html =~ "Printing"
assert html =~ "Delivery"
end
test "moving a repeater item changes order", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/contact")
page = Pages.get_page("contact")
info_card = Enum.find(page.blocks, &(&1["type"] == "info_card"))
render_click(view, "toggle_expand", %{"id" => info_card["id"]})
render_click(view, "repeater_move", %{
"block-id" => info_card["id"],
"field" => "items",
"index" => "0",
"dir" => "down"
})
# Save and check the persisted order
render_click(view, "save")
saved = Pages.get_page("contact")
saved_info = Enum.find(saved.blocks, &(&1["type"] == "info_card"))
labels = Enum.map(saved_info["settings"]["items"], & &1["label"])
# "Printing" moved from 0 to 1
assert labels == ["Delivery", "Printing", "Issues"]
end
test "repeater changes persist after save", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/contact")
page = Pages.get_page("contact")
info_card = Enum.find(page.blocks, &(&1["type"] == "info_card"))
render_click(view, "toggle_expand", %{"id" => info_card["id"]})
# Edit item via phx-change
render_change(view, "update_block_settings", %{
"block_id" => info_card["id"],
"block_settings" => %{
"title" => "Good to know",
"items" => %{
"0" => %{"label" => "Shipping", "value" => "Fast shipping"},
"1" => %{"label" => "Returns", "value" => "Easy returns"}
}
}
})
render_click(view, "save")
saved = Pages.get_page("contact")
saved_info = Enum.find(saved.blocks, &(&1["type"] == "info_card"))
assert saved_info["settings"]["title"] == "Good to know"
assert length(saved_info["settings"]["items"]) == 2
assert Enum.at(saved_info["settings"]["items"], 0)["label"] == "Shipping"
assert Enum.at(saved_info["settings"]["items"], 0)["value"] == "Fast shipping"
end
end
defp count_repeater_items(html) do
Regex.scan(~r/class="repeater-item"/, html) |> length()
end
end end