add admin CRUD for custom CMS pages
All checks were successful
deploy / deploy (push) Successful in 1m21s
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:
parent
bb6b28a163
commit
045be2ed7e
12
PROGRESS.md
12
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
|
||||
|
||||
@ -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 {
|
||||
|
||||
158
lib/berrypod_web/live/admin/pages/custom_form.ex
Normal file
158
lib/berrypod_web/live/admin/pages/custom_form.ex
Normal 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"
|
||||
>
|
||||
← 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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user