From 6fbd654d5707dde946914773cd3d3e0c8f1eb5a6 Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 27 Feb 2026 00:54:13 +0000 Subject: [PATCH] add SettingsField struct and repeater field type for block settings 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 --- assets/css/admin/components.css | 55 ++++- lib/berrypod/pages/block_types.ex | 74 +++++-- lib/berrypod/pages/settings_field.ex | 16 ++ lib/berrypod_web/live/admin/pages/editor.ex | 228 ++++++++++++++++++-- test/berrypod_web/live/admin/pages_test.exs | 149 +++++++++++++ 5 files changed, 470 insertions(+), 52 deletions(-) create mode 100644 lib/berrypod/pages/settings_field.ex diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 7b10760..7706229 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1346,16 +1346,55 @@ gap: 0.625rem; } -.block-settings-json { - font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; - font-size: 0.75rem; - opacity: 0.7; +/* Repeater fields */ + +.repeater-field { + display: flex; + flex-direction: column; + gap: 0.5rem; } -.block-settings-hint { - font-size: 0.75rem; - color: color-mix(in oklch, var(--t-text-primary) 50%, transparent); - margin-top: 0.25rem; +.repeater-items { + list-style: none; + padding: 0; + 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 */ diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex index 19663d6..c87552a 100644 --- a/lib/berrypod/pages/block_types.ex +++ b/lib/berrypod/pages/block_types.ex @@ -7,6 +7,7 @@ defmodule Berrypod.Pages.BlockTypes do is present on a page. """ + alias Berrypod.Pages.SettingsField alias Berrypod.Products alias Berrypod.Theme.PreviewData @@ -18,13 +19,23 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-megaphone", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: ""}, - %{key: "description", label: "Description", type: :textarea, default: ""}, - %{key: "cta_text", label: "Button text", type: :text, default: ""}, - %{key: "cta_href", label: "Button link", type: :text, default: ""}, - %{key: "secondary_cta_text", label: "Secondary button text", type: :text, default: ""}, - %{key: "secondary_cta_href", label: "Secondary button link", type: :text, default: ""}, - %{ + %SettingsField{key: "title", label: "Title", type: :text, default: ""}, + %SettingsField{key: "description", label: "Description", type: :textarea, default: ""}, + %SettingsField{key: "cta_text", label: "Button text", type: :text, default: ""}, + %SettingsField{key: "cta_href", label: "Button link", type: :text, default: ""}, + %SettingsField{ + 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", label: "Style", type: :select, @@ -38,23 +49,33 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-star", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: "Featured products"}, - %{key: "product_count", label: "Number of products", type: :number, default: 8}, - %{ + %SettingsField{ + key: "title", + label: "Title", + type: :text, + default: "Featured products" + }, + %SettingsField{ + key: "product_count", + label: "Number of products", + type: :number, + default: 8 + }, + %SettingsField{ key: "layout", label: "Layout", type: :select, options: ~w(section grid), default: "section" }, - %{ + %SettingsField{ key: "card_variant", label: "Card style", type: :select, options: ~w(featured default minimal compact), default: "featured" }, - %{ + %SettingsField{ key: "columns", label: "Columns", type: :select, @@ -69,11 +90,11 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-photo", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: ""}, - %{key: "description", label: "Description", type: :textarea, default: ""}, - %{key: "image_url", label: "Image URL", type: :text, default: ""}, - %{key: "link_text", label: "Link text", type: :text, default: ""}, - %{key: "link_href", label: "Link URL", type: :text, default: ""} + %SettingsField{key: "title", label: "Title", type: :text, default: ""}, + %SettingsField{key: "description", label: "Description", type: :textarea, default: ""}, + %SettingsField{key: "image_url", label: "Image URL", type: :text, default: ""}, + %SettingsField{key: "link_text", label: "Link text", type: :text, default: ""}, + %SettingsField{key: "link_href", label: "Link URL", type: :text, default: ""} ] }, "category_nav" => %{ @@ -99,8 +120,17 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-information-circle", allowed_on: :all, settings_schema: [ - %{key: "title", label: "Title", type: :text, default: ""}, - %{key: "items", label: "Items", type: :json, default: []} + %SettingsField{key: "title", label: "Title", type: :text, 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" => %{ @@ -188,7 +218,7 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-envelope", allowed_on: ["contact"], 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" => %{ @@ -205,8 +235,8 @@ defmodule Berrypod.Pages.BlockTypes do icon: "hero-document-text", allowed_on: ["about", "delivery", "privacy", "terms"], settings_schema: [ - %{key: "image_src", label: "Image", type: :text, default: ""}, - %{key: "image_alt", label: "Image alt text", type: :text, default: ""} + %SettingsField{key: "image_src", label: "Image", type: :text, default: ""}, + %SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""} ] }, diff --git a/lib/berrypod/pages/settings_field.ex b/lib/berrypod/pages/settings_field.ex new file mode 100644 index 0000000..62eec25 --- /dev/null +++ b/lib/berrypod/pages/settings_field.ex @@ -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 diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 31c975c..0c59a81 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view alias Berrypod.Pages - alias Berrypod.Pages.{BlockTypes, Defaults} + alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField} @impl true def mount(%{"slug" => slug}, _session, socket) do @@ -162,6 +162,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do 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 {:noreply, socket @@ -470,34 +552,114 @@ defmodule BerrypodWeb.Admin.Pages.Editor do """ 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 + defp block_field(%{field: %{type: :repeater}} = assigns) do + items = if is_list(assigns.value), do: assigns.value, else: [] + item_count = length(items) - assigns = assign(assigns, :json_str, json_str) + assigns = + assigns + |> assign(:items, items) + |> assign(:item_count, item_count) ~H""" -
- +
+ {@field.label} + +
    +
  1. +
    + + Item {idx + 1}: {item["label"] || item[:label] || "New item"} + + +
    + <.repeater_item_field + :for={sub_field <- @field.item_schema} + sub_field={sub_field} + item={item} + field_key={@field.key} + block_id={@block_id} + index={idx} + /> +
    + +
    + + + +
    +
    +
  2. +
+ +
""" end + defp repeater_item_field(assigns) do + value = assigns.item[assigns.sub_field.key] || "" + + assigns = assign(assigns, :value, value) + + ~H""" + + """ + end + defp block_field(assigns) do ~H"""
@@ -589,12 +751,34 @@ defmodule BerrypodWeb.Admin.Pages.Editor do %{type: :number, default: default} -> {key, parse_number(value, default)} + %{type: :repeater} -> + {key, indexed_map_to_list(value)} + _ -> {key, value} 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 case Integer.parse(value) do {n, ""} -> n diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 1ec932e..7ba3086 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -478,4 +478,153 @@ defmodule BerrypodWeb.Admin.PagesTest do assert has_element?(view, "#block-#{hero["id"]}.block-card-expanded") 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