add admin CRUD for custom CMS pages
All checks were successful
deploy / deploy (push) Successful in 1m21s

New settings form for creating and editing custom page metadata
(title, slug, meta description, published, nav settings). Pages
index shows custom pages section with draft badges and delete.
Editor shows settings button for custom pages, hides reset to
defaults. 20 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 09:43:03 +00:00
parent bb6b28a163
commit 045be2ed7e
8 changed files with 473 additions and 8 deletions

View File

@ -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
- 1398 tests passing, 100% PageSpeed score
- 1455 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)
@ -143,9 +143,9 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~95~~ | ~~Image picker for page builder — `:image` field type, image_id resolution in renderer~~ | 94 | 2h | done |
| 96 | Polish — theme editor alt text, full modal picker, orphan cleanup on ref removal | 95 | 1h | planned |
| | **Custom CMS pages** ([plan](docs/plans/custom-pages.md)) | | | |
| 97 | Stage 1: data model + context — schema fields, split changeset, CRUD functions, cache | — | 1.5h | planned |
| 98 | Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling | 97 | 1h | planned |
| 99 | Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index | 98 | 2.5h | planned |
| ~~97~~ | ~~Stage 1: data model + context — schema fields, split changeset, CRUD functions, cache~~ | — | 1.5h | done |
| ~~98~~ | ~~Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling~~ | 97 | 1h | done |
| ~~99~~ | ~~Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index~~ | 98 | 2.5h | done |
| 100 | Stage 4: navigation management — data-driven nav, settings storage, admin editor | 99 | 3h | planned |
| 101 | Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published | 100 | 1h | planned |
| 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred |
@ -487,7 +487,7 @@ Admin media library at `/admin/media` with image grid, type/search/orphan filter
See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan
### Page Editor
**Status:** In progress — Stage 8 of 9 complete, 1370 tests
**Status:** In progress — Stage 8 of 9 complete, 1455 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).
@ -508,7 +508,7 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON
- `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/pages_test.exs`62 tests
- `test/berrypod_web/page_renderer_test.exs` — 18 tests
- `lib/berrypod/pages/settings_field.ex` — typed struct for settings schema fields
- `lib/berrypod/pages/block_editor.ex` — pure functions for block manipulation

View File

@ -1174,6 +1174,45 @@
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
}
.page-card-custom {
padding: 0;
display: flex;
align-items: stretch;
}
.page-card-link {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0 0.75rem 1rem;
text-decoration: none;
color: var(--t-text-primary);
min-width: 0;
@media (hover: hover) {
&:hover {
background: var(--t-surface-sunken);
}
}
}
.page-card-delete {
display: flex;
align-items: center;
padding: 0 0.75rem;
color: color-mix(in oklch, var(--t-text-primary) 30%, transparent);
cursor: pointer;
border: none;
background: none;
@media (hover: hover) {
&:hover {
color: var(--t-error);
}
}
}
/* Page editor split layout */
.page-editor-container {

View File

@ -0,0 +1,158 @@
defmodule BerrypodWeb.Admin.Pages.CustomForm do
use BerrypodWeb, :live_view
alias Berrypod.Pages
alias Berrypod.Pages.Page
@impl true
def mount(params, _session, socket) do
{:ok, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
changeset = Page.custom_changeset(%Page{}, %{})
socket
|> assign(:page_title, "New page")
|> assign(:page_struct, %Page{})
|> assign(:slug_touched, false)
|> assign(:form, to_form(changeset))
end
defp apply_action(socket, :edit, %{"slug" => slug}) do
case Pages.get_page_struct(slug) do
%Page{type: "custom"} = page ->
changeset = Page.custom_changeset(page, %{})
socket
|> assign(:page_title, "#{page.title} settings")
|> assign(:page_struct, page)
|> assign(:slug_touched, true)
|> assign(:form, to_form(changeset))
_ ->
socket
|> put_flash(:error, "Page not found")
|> push_navigate(to: ~p"/admin/pages")
end
end
@impl true
def handle_event("validate", %{"page" => params}, socket) do
params = maybe_slugify(params, socket)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
slug_touched =
socket.assigns.slug_touched || (params["slug"] && params["slug"] != "")
{:noreply, assign(socket, form: form, slug_touched: slug_touched)}
end
@impl true
def handle_event("save", %{"page" => params}, socket) do
save_page(socket, socket.assigns.live_action, params)
end
defp save_page(socket, :new, params) do
case Pages.create_custom_page(params) do
{:ok, page} ->
{:noreply,
socket
|> put_flash(:info, "Page created")
|> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_page(socket, :edit, params) do
case Pages.update_custom_page(socket.assigns.page_struct, params) do
{:ok, page} ->
{:noreply,
socket
|> put_flash(:info, "Page settings saved")
|> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
# Auto-generate slug from title when creating and slug hasn't been manually edited
defp maybe_slugify(params, %{assigns: %{live_action: :new, slug_touched: false}}) do
case params["title"] do
title when is_binary(title) and title != "" ->
slug =
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s-]/, "")
|> String.trim()
|> String.replace(~r/\s+/, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
Map.put(params, "slug", slug)
_ ->
params
end
end
defp maybe_slugify(params, _socket), do: params
@impl true
def render(assigns) do
~H"""
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; Pages
</.link>
<.header>
{if @live_action == :new, do: "New page", else: "Page settings"}
</.header>
<.form
for={@form}
id="custom-page-form"
phx-change="validate"
phx-submit="save"
class="mt-6 space-y-6"
style="max-width: 32rem;"
>
<.input field={@form[:title]} label="Title" />
<.input field={@form[:slug]} label="URL slug" />
<.input
field={@form[:meta_description]}
type="textarea"
label="Meta description"
phx-no-feedback
/>
<.input field={@form[:published]} type="checkbox" label="Published" />
<.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" />
<div :if={@form[:show_in_nav].value == true} class="space-y-4 pl-6">
<.input field={@form[:nav_label]} label="Nav label" />
<.input field={@form[:nav_position]} type="number" label="Nav position" />
</div>
<div class="flex gap-3">
<.button type="submit" phx-disable-with="Saving...">
{if @live_action == :new, do: "Create page", else: "Save settings"}
</.button>
<.link navigate={~p"/admin/pages"} class="admin-btn admin-btn-ghost">
Cancel
</.link>
</div>
</.form>
"""
end
end

View File

@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
alias Berrypod.Pages.{BlockEditor, BlockTypes}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
@ -38,7 +38,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
|> assign(:image_picker_search, "")}
|> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug))}
end
# ── Block manipulation events ────────────────────────────────────
@ -310,7 +311,15 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
/>
{if @show_preview, do: "Edit", else: "Preview"}
</button>
<.link
:if={@is_custom_page}
navigate={~p"/admin/pages/#{@slug}/settings"}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link>
<button
:if={!@is_custom_page}
phx-click="reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"

View File

@ -14,20 +14,43 @@ defmodule BerrypodWeb.Admin.Pages.Index do
@impl true
def mount(_params, _session, socket) do
pages = Pages.list_pages() |> Map.new(&{&1.slug, &1})
custom_pages = Pages.list_custom_pages()
{:ok,
socket
|> assign(:page_title, "Pages")
|> assign(:pages, pages)
|> assign(:custom_pages, custom_pages)
|> assign(:page_groups, @page_groups)}
end
@impl true
def handle_event("delete_custom_page", %{"slug" => slug}, socket) do
case Pages.get_page_struct(slug) do
%{type: "custom"} = page ->
{:ok, _} = Pages.delete_custom_page(page)
{:noreply,
socket
|> assign(:custom_pages, Pages.list_custom_pages())
|> put_flash(:info, "Page deleted")}
_ ->
{:noreply, put_flash(socket, :error, "Page not found")}
end
end
@impl true
def render(assigns) do
~H"""
<.header>
Pages
<:subtitle>Customise the layout and content of every page on your shop.</:subtitle>
<:actions>
<.link navigate={~p"/admin/pages/new"} class="admin-btn admin-btn-sm admin-btn-primary">
<.icon name="hero-plus" class="size-4" /> New page
</.link>
</:actions>
</.header>
<div class="page-list">
@ -54,6 +77,42 @@ defmodule BerrypodWeb.Admin.Pages.Index do
</.link>
</div>
</div>
<div :if={@custom_pages != []} class="page-group">
<h3 class="page-group-title">Custom pages</h3>
<div class="page-group-cards">
<div :for={page <- @custom_pages} class="page-card page-card-custom">
<.link navigate={~p"/admin/pages/#{page.slug}"} class="page-card-link">
<span class="page-card-icon">
<.icon name="hero-document" class="size-5" />
</span>
<span class="page-card-info">
<span class="page-card-title">
{page.title}
<span :if={!page.published} class="admin-badge admin-badge-warning ml-2">
Draft
</span>
</span>
<span class="page-card-meta">
/{page.slug} · {length(page.blocks)} {if length(page.blocks) == 1,
do: "block",
else: "blocks"}
</span>
</span>
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
</.link>
<button
phx-click="delete_custom_page"
phx-value-slug={page.slug}
data-confirm={"Delete \"#{page.title}\"? This cannot be undone."}
class="page-card-delete"
aria-label={"Delete #{page.title}"}
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
</div>
</div>
</div>
"""
end

View File

@ -155,6 +155,8 @@ defmodule BerrypodWeb.Router do
live "/settings", Admin.Settings, :index
live "/settings/email", Admin.EmailSettings, :index
live "/pages", Admin.Pages.Index, :index
live "/pages/new", Admin.Pages.CustomForm, :new
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
live "/pages/:slug", Admin.Pages.Editor, :edit
live "/media", Admin.Media, :index
live "/redirects", Admin.Redirects, :index

View File

@ -701,6 +701,202 @@ defmodule BerrypodWeb.Admin.PagesTest do
end
end
describe "custom pages on index" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "shows new page button", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages")
assert has_element?(view, "a[href='/admin/pages/new']", "New page")
end
test "shows custom pages section when pages exist", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
{:ok, view, _html} = live(conn, ~p"/admin/pages")
assert has_element?(view, ".page-group-title", "Custom pages")
assert has_element?(view, ".page-card-title", "FAQ")
end
test "does not show custom pages section when empty", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages")
refute has_element?(view, ".page-group-title", "Custom pages")
end
test "shows draft badge for unpublished pages", %{conn: conn} do
{:ok, _} =
Pages.create_custom_page(%{slug: "draft-page", title: "Draft", published: false})
{:ok, view, _html} = live(conn, ~p"/admin/pages")
assert has_element?(view, ".admin-badge-warning", "Draft")
end
test "custom page card links to editor", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
{:ok, view, _html} = live(conn, ~p"/admin/pages")
assert has_element?(view, "a[href='/admin/pages/faq']")
end
test "shows slug in card meta", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "our-story", title: "Our story"})
{:ok, view, _html} = live(conn, ~p"/admin/pages")
assert has_element?(view, ".page-card-meta", "/our-story")
end
test "delete removes page from list", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
{:ok, view, _html} = live(conn, ~p"/admin/pages")
assert has_element?(view, ".page-card-title", "FAQ")
view
|> element("button[phx-click='delete_custom_page'][phx-value-slug='faq']")
|> render_click()
refute has_element?(view, ".page-card-title", "FAQ")
assert render(view) =~ "Page deleted"
end
end
describe "custom page creation" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "renders creation form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/new")
assert has_element?(view, "h1", "New page")
assert has_element?(view, "#custom-page-form")
end
test "creating with valid data redirects to editor", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/new")
view
|> form("#custom-page-form", page: %{title: "FAQ", slug: "faq"})
|> render_submit()
assert_redirect(view, ~p"/admin/pages/faq")
end
test "creating with empty title shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/new")
view
|> form("#custom-page-form", page: %{title: "", slug: "faq"})
|> render_submit()
assert has_element?(view, "#custom-page-form")
end
test "creating with reserved slug shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/new")
html =
view
|> form("#custom-page-form", page: %{title: "Admin", slug: "admin"})
|> render_submit()
assert html =~ "is reserved"
end
test "creating with duplicate slug shows error", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
{:ok, view, _html} = live(conn, ~p"/admin/pages/new")
html =
view
|> form("#custom-page-form", page: %{title: "FAQ 2", slug: "faq"})
|> render_submit()
assert html =~ "has already been taken"
end
test "auto-slugifies title during validation", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/new")
html =
view
|> form("#custom-page-form", page: %{title: "Our Story"})
|> render_change()
assert html =~ ~s(value="our-story")
end
end
describe "custom page settings" do
setup %{conn: conn, user: user} do
{:ok, _} = Pages.create_custom_page(%{slug: "help-page", title: "Help"})
%{conn: log_in_user(conn, user)}
end
test "renders settings form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/help-page/settings")
assert has_element?(view, "h1", "Page settings")
assert has_element?(view, "#custom-page-form")
end
test "updating title saves correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/help-page/settings")
view
|> form("#custom-page-form", page: %{title: "Help centre"})
|> render_submit()
assert_redirect(view, ~p"/admin/pages/help-page")
page = Pages.get_page("help-page")
assert page.title == "Help centre"
end
test "updating slug redirects to new editor path", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/help-page/settings")
view
|> form("#custom-page-form", page: %{slug: "support"})
|> render_submit()
assert_redirect(view, ~p"/admin/pages/support")
end
test "settings for nonexistent page redirects", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/admin/pages"}}} =
live(conn, ~p"/admin/pages/nonexistent/settings")
end
end
describe "editor for custom pages" do
setup %{conn: conn, user: user} do
{:ok, _} = Pages.create_custom_page(%{slug: "size-guide", title: "Size guide"})
%{conn: log_in_user(conn, user)}
end
test "shows settings button for custom pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/size-guide")
assert has_element?(view, "a[href='/admin/pages/size-guide/settings']", "Settings")
end
test "does not show reset to defaults for custom pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/size-guide")
refute has_element?(view, "button", "Reset to defaults")
end
test "shows reset to defaults for system pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/home")
assert has_element?(view, "button", "Reset to defaults")
refute has_element?(view, "a[href='/admin/pages/home/settings']")
end
end
defp count_repeater_items(html) do
Regex.scan(~r/class="repeater-item"/, html) |> length()
end

View File

@ -9,6 +9,8 @@ defmodule BerrypodWeb.Shop.CustomPageTest do
setup do
PageCache.invalidate_all()
# Clear redirect ETS cache to avoid stale entries from other tests
if :ets.whereis(:redirects_cache) != :undefined, do: :ets.delete_all_objects(:redirects_cache)
user = user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
%{user: user}