From 045be2ed7e69abbe3c58abc97fa0088a28872435 Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 28 Feb 2026 09:43:03 +0000 Subject: [PATCH] add admin CRUD for custom CMS pages 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 --- PROGRESS.md | 12 +- assets/css/admin/components.css | 39 ++++ .../live/admin/pages/custom_form.ex | 158 ++++++++++++++ lib/berrypod_web/live/admin/pages/editor.ex | 13 +- lib/berrypod_web/live/admin/pages/index.ex | 59 ++++++ lib/berrypod_web/router.ex | 2 + test/berrypod_web/live/admin/pages_test.exs | 196 ++++++++++++++++++ .../live/shop/custom_page_test.exs | 2 + 8 files changed, 473 insertions(+), 8 deletions(-) create mode 100644 lib/berrypod_web/live/admin/pages/custom_form.ex diff --git a/PROGRESS.md b/PROGRESS.md index 485b0ae..9fc2bf1 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 -- 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 diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index b2fbd4c..36ff07a 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -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 { diff --git a/lib/berrypod_web/live/admin/pages/custom_form.ex b/lib/berrypod_web/live/admin/pages/custom_form.ex new file mode 100644 index 0000000..e363fca --- /dev/null +++ b/lib/berrypod_web/live/admin/pages/custom_form.ex @@ -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" + > + ← Pages + + + <.header> + {if @live_action == :new, do: "New page", else: "Page settings"} + + + <.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" /> + +
+ <.input field={@form[:nav_label]} label="Nav label" /> + <.input field={@form[:nav_position]} type="number" label="Nav position" /> +
+ +
+ <.button type="submit" phx-disable-with="Saving..."> + {if @live_action == :new, do: "Create page", else: "Save settings"} + + <.link navigate={~p"/admin/pages"} class="admin-btn admin-btn-ghost"> + Cancel + +
+ + """ + end +end diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index 8b93d0a..8c65a8f 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.{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"} + <.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 + + + + """ end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index fc52685..5b1cede 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -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 diff --git a/test/berrypod_web/live/admin/pages_test.exs b/test/berrypod_web/live/admin/pages_test.exs index 3389259..12144b6 100644 --- a/test/berrypod_web/live/admin/pages_test.exs +++ b/test/berrypod_web/live/admin/pages_test.exs @@ -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 diff --git a/test/berrypod_web/live/shop/custom_page_test.exs b/test/berrypod_web/live/shop/custom_page_test.exs index 0bc6f2b..0545dfe 100644 --- a/test/berrypod_web/live/shop/custom_page_test.exs +++ b/test/berrypod_web/live/shop/custom_page_test.exs @@ -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}