add page builder data model, cache, and block registry
Stage 1 of the page builder: Pages schema with 14 valid slugs, BlockTypes registry (26 block types with settings schemas and data loaders), Defaults module matching existing templates, ETS-backed PageCache GenServer, and Pages context (get_page/save_page/reset_page with cache -> DB -> defaults lookup). 34 tests, zero visual change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c54861eb6
commit
35f96e43a6
@ -36,7 +36,9 @@ defmodule Berrypod.Application do
|
||||
# Start to serve requests
|
||||
BerrypodWeb.Endpoint,
|
||||
# Theme CSS cache - must start after Endpoint for static_path/1 to work
|
||||
Berrypod.Theme.CSSCache
|
||||
Berrypod.Theme.CSSCache,
|
||||
# Page definition cache - loads page block lists into ETS
|
||||
Berrypod.Pages.PageCache
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
|
||||
126
lib/berrypod/pages.ex
Normal file
126
lib/berrypod/pages.ex
Normal file
@ -0,0 +1,126 @@
|
||||
defmodule Berrypod.Pages do
|
||||
@moduledoc """
|
||||
Context for database-driven page definitions.
|
||||
|
||||
Every page is a flat list of blocks stored as JSON. This module provides
|
||||
the public API for reading, writing, and loading block data for pages.
|
||||
|
||||
Lookup order: ETS cache -> DB -> defaults.
|
||||
"""
|
||||
|
||||
alias Berrypod.Repo
|
||||
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
# ── Read ──────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Gets a page definition by slug.
|
||||
|
||||
Checks the ETS cache first, falls back to DB, then to built-in defaults.
|
||||
Returns a map with `:slug`, `:title`, and `:blocks` keys.
|
||||
"""
|
||||
def get_page(slug) do
|
||||
case PageCache.get(slug) do
|
||||
{:ok, page_data} -> page_data
|
||||
:miss -> get_page_uncached(slug)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a page from the DB, falling back to defaults. Used by PageCache.warm/0.
|
||||
"""
|
||||
def get_page_from_db(slug) do
|
||||
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
||||
%Page{} = page -> page_to_map(page)
|
||||
nil -> Defaults.for_slug(slug)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_page_uncached(slug) do
|
||||
page_data = get_page_from_db(slug)
|
||||
|
||||
# Cache for next time (best-effort, ETS might not be up yet)
|
||||
try do
|
||||
PageCache.put(slug, page_data)
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
end
|
||||
|
||||
page_data
|
||||
end
|
||||
|
||||
@doc "Lists all pages with their current definitions."
|
||||
def list_pages do
|
||||
Page.valid_slugs()
|
||||
|> Enum.map(&get_page/1)
|
||||
end
|
||||
|
||||
# ── Write ─────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Saves a page's block list. Creates or updates the DB row and invalidates
|
||||
the cache.
|
||||
|
||||
Returns `{:ok, page}` or `{:error, changeset}`.
|
||||
"""
|
||||
def save_page(slug, attrs) do
|
||||
existing = Repo.one(from p in Page, where: p.slug == ^slug)
|
||||
|
||||
result =
|
||||
case existing do
|
||||
nil ->
|
||||
%Page{}
|
||||
|> Page.changeset(Map.merge(%{slug: slug}, attrs))
|
||||
|> Repo.insert()
|
||||
|
||||
%Page{} = page ->
|
||||
page
|
||||
|> Page.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, page} ->
|
||||
PageCache.invalidate(slug)
|
||||
{:ok, page}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets a page to its default block list. Deletes the DB row (if any)
|
||||
and invalidates the cache.
|
||||
"""
|
||||
def reset_page(slug) do
|
||||
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
||||
nil -> :ok
|
||||
page -> Repo.delete(page)
|
||||
end
|
||||
|
||||
PageCache.invalidate(slug)
|
||||
:ok
|
||||
end
|
||||
|
||||
# ── Block data loading ────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Runs data loaders for all blocks on a page, returning a merged map
|
||||
of extra assigns.
|
||||
|
||||
Only blocks with a `data_loader` in the BlockTypes registry trigger
|
||||
a query. Blocks without data loaders rely on global or page-context assigns.
|
||||
"""
|
||||
def load_block_data(blocks, assigns) do
|
||||
BlockTypes.load_data(blocks, assigns)
|
||||
end
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp page_to_map(%Page{} = page) do
|
||||
%{slug: page.slug, title: page.title, blocks: page.blocks}
|
||||
end
|
||||
end
|
||||
300
lib/berrypod/pages/block_types.ex
Normal file
300
lib/berrypod/pages/block_types.ex
Normal file
@ -0,0 +1,300 @@
|
||||
defmodule Berrypod.Pages.BlockTypes do
|
||||
@moduledoc """
|
||||
Registry of all block types available in the page builder.
|
||||
|
||||
Each block type has a name, icon, allowed pages, settings schema,
|
||||
and an optional data loader that fetches extra data when the block
|
||||
is present on a page.
|
||||
"""
|
||||
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Theme.PreviewData
|
||||
|
||||
@block_types %{
|
||||
# ── Portable blocks (work on any page) ──────────────────────────
|
||||
|
||||
"hero" => %{
|
||||
name: "Hero banner",
|
||||
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: "variant",
|
||||
label: "Style",
|
||||
type: :select,
|
||||
options: ~w(default page sunken error),
|
||||
default: "default"
|
||||
}
|
||||
]
|
||||
},
|
||||
"featured_products" => %{
|
||||
name: "Featured products",
|
||||
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}
|
||||
],
|
||||
data_loader: :load_featured_products
|
||||
},
|
||||
"image_text" => %{
|
||||
name: "Image + text",
|
||||
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: ""}
|
||||
]
|
||||
},
|
||||
"category_nav" => %{
|
||||
name: "Category navigation",
|
||||
icon: "hero-squares-2x2",
|
||||
allowed_on: :all,
|
||||
settings_schema: []
|
||||
},
|
||||
"newsletter_card" => %{
|
||||
name: "Newsletter signup",
|
||||
icon: "hero-envelope",
|
||||
allowed_on: :all,
|
||||
settings_schema: []
|
||||
},
|
||||
"social_links_card" => %{
|
||||
name: "Social links",
|
||||
icon: "hero-share",
|
||||
allowed_on: :all,
|
||||
settings_schema: []
|
||||
},
|
||||
"info_card" => %{
|
||||
name: "Info card",
|
||||
icon: "hero-information-circle",
|
||||
allowed_on: :all,
|
||||
settings_schema: [
|
||||
%{key: "title", label: "Title", type: :text, default: ""},
|
||||
%{key: "items", label: "Items", type: :json, default: []}
|
||||
]
|
||||
},
|
||||
"trust_badges" => %{
|
||||
name: "Trust badges",
|
||||
icon: "hero-shield-check",
|
||||
allowed_on: :all,
|
||||
settings_schema: []
|
||||
},
|
||||
"reviews_section" => %{
|
||||
name: "Customer reviews",
|
||||
icon: "hero-chat-bubble-left-right",
|
||||
allowed_on: :all,
|
||||
settings_schema: [],
|
||||
data_loader: :load_reviews
|
||||
},
|
||||
|
||||
# ── PDP blocks ──────────────────────────────────────────────────
|
||||
|
||||
"product_hero" => %{
|
||||
name: "Product hero",
|
||||
icon: "hero-cube",
|
||||
allowed_on: ["pdp"],
|
||||
settings_schema: []
|
||||
},
|
||||
"breadcrumb" => %{
|
||||
name: "Breadcrumb",
|
||||
icon: "hero-chevron-right",
|
||||
allowed_on: ["pdp"],
|
||||
settings_schema: []
|
||||
},
|
||||
"product_details" => %{
|
||||
name: "Product details",
|
||||
icon: "hero-document-text",
|
||||
allowed_on: ["pdp"],
|
||||
settings_schema: []
|
||||
},
|
||||
"related_products" => %{
|
||||
name: "Related products",
|
||||
icon: "hero-squares-plus",
|
||||
allowed_on: ["pdp"],
|
||||
settings_schema: [],
|
||||
data_loader: :load_related_products
|
||||
},
|
||||
|
||||
# ── Collection blocks ───────────────────────────────────────────
|
||||
|
||||
"collection_header" => %{
|
||||
name: "Collection header",
|
||||
icon: "hero-tag",
|
||||
allowed_on: ["collection"],
|
||||
settings_schema: []
|
||||
},
|
||||
"filter_bar" => %{
|
||||
name: "Filter bar",
|
||||
icon: "hero-funnel",
|
||||
allowed_on: ["collection"],
|
||||
settings_schema: []
|
||||
},
|
||||
"product_grid" => %{
|
||||
name: "Product grid",
|
||||
icon: "hero-squares-2x2",
|
||||
allowed_on: ["collection"],
|
||||
settings_schema: []
|
||||
},
|
||||
|
||||
# ── Cart blocks ─────────────────────────────────────────────────
|
||||
|
||||
"cart_items" => %{
|
||||
name: "Cart items",
|
||||
icon: "hero-shopping-cart",
|
||||
allowed_on: ["cart"],
|
||||
settings_schema: []
|
||||
},
|
||||
"order_summary" => %{
|
||||
name: "Order summary",
|
||||
icon: "hero-calculator",
|
||||
allowed_on: ["cart"],
|
||||
settings_schema: []
|
||||
},
|
||||
|
||||
# ── Contact blocks ──────────────────────────────────────────────
|
||||
|
||||
"contact_form" => %{
|
||||
name: "Contact form",
|
||||
icon: "hero-envelope",
|
||||
allowed_on: ["contact"],
|
||||
settings_schema: [
|
||||
%{key: "email", label: "Email", type: :text, default: "hello@example.com"}
|
||||
]
|
||||
},
|
||||
"order_tracking_card" => %{
|
||||
name: "Order tracking",
|
||||
icon: "hero-truck",
|
||||
allowed_on: ["contact"],
|
||||
settings_schema: []
|
||||
},
|
||||
|
||||
# ── Content blocks ──────────────────────────────────────────────
|
||||
|
||||
"content_body" => %{
|
||||
name: "Page content",
|
||||
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: ""}
|
||||
]
|
||||
},
|
||||
|
||||
# ── Checkout success ────────────────────────────────────────────
|
||||
|
||||
"checkout_result" => %{
|
||||
name: "Checkout result",
|
||||
icon: "hero-check-circle",
|
||||
allowed_on: ["checkout_success"],
|
||||
settings_schema: []
|
||||
},
|
||||
|
||||
# ── Orders ──────────────────────────────────────────────────────
|
||||
|
||||
"order_card" => %{
|
||||
name: "Order cards",
|
||||
icon: "hero-clipboard-document-list",
|
||||
allowed_on: ["orders"],
|
||||
settings_schema: []
|
||||
},
|
||||
|
||||
# ── Order detail ────────────────────────────────────────────────
|
||||
|
||||
"order_detail_card" => %{
|
||||
name: "Order detail",
|
||||
icon: "hero-clipboard-document",
|
||||
allowed_on: ["order_detail"],
|
||||
settings_schema: []
|
||||
},
|
||||
|
||||
# ── Search ──────────────────────────────────────────────────────
|
||||
|
||||
"search_results" => %{
|
||||
name: "Search results",
|
||||
icon: "hero-magnifying-glass",
|
||||
allowed_on: ["search"],
|
||||
settings_schema: []
|
||||
}
|
||||
}
|
||||
|
||||
@doc "Returns the full block type registry."
|
||||
def all, do: @block_types
|
||||
|
||||
@doc "Returns the definition for a single block type, or nil."
|
||||
def get(type), do: Map.get(@block_types, type)
|
||||
|
||||
@doc "Returns block types allowed on the given page slug."
|
||||
def allowed_for(page_slug) do
|
||||
@block_types
|
||||
|> Enum.filter(fn {_type, def} ->
|
||||
def.allowed_on == :all or page_slug in def.allowed_on
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs data loaders for a list of blocks, merging results into a single map.
|
||||
|
||||
Each block type can declare a `data_loader` — a function that returns
|
||||
extra assigns needed by that block. Only fires when the block is on the page.
|
||||
"""
|
||||
def load_data(blocks, assigns) do
|
||||
blocks
|
||||
|> Enum.reduce(%{}, fn block, acc ->
|
||||
case get(block["type"]) do
|
||||
%{data_loader: loader} ->
|
||||
Map.merge(acc, run_loader(loader, assigns, block["settings"] || %{}))
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# ── Data loaders ────────────────────────────────────────────────
|
||||
|
||||
defp run_loader(:load_featured_products, assigns, settings) do
|
||||
limit = settings["product_count"] || 8
|
||||
|
||||
products =
|
||||
if assigns[:mode] == :preview do
|
||||
PreviewData.products() |> Enum.take(limit)
|
||||
else
|
||||
Products.list_visible_products(limit: limit)
|
||||
end
|
||||
|
||||
%{products: products}
|
||||
end
|
||||
|
||||
defp run_loader(:load_related_products, assigns, _settings) do
|
||||
products =
|
||||
if assigns[:mode] == :preview do
|
||||
PreviewData.products() |> Enum.take(4)
|
||||
else
|
||||
case assigns[:product] do
|
||||
%{category: cat, id: id} when not is_nil(cat) ->
|
||||
Products.list_visible_products(category: cat, limit: 4, exclude: id)
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
%{related_products: products}
|
||||
end
|
||||
|
||||
defp run_loader(:load_reviews, _assigns, _settings) do
|
||||
%{
|
||||
reviews: PreviewData.reviews(),
|
||||
average_rating: 5,
|
||||
total_count: 24
|
||||
}
|
||||
end
|
||||
end
|
||||
212
lib/berrypod/pages/defaults.ex
Normal file
212
lib/berrypod/pages/defaults.ex
Normal file
@ -0,0 +1,212 @@
|
||||
defmodule Berrypod.Pages.Defaults do
|
||||
@moduledoc """
|
||||
Default block definitions for all pages.
|
||||
|
||||
These match the current static templates exactly. When a page has never
|
||||
been customised, `Pages.get_page/1` returns these defaults. First edit
|
||||
saves to the DB and overrides them.
|
||||
"""
|
||||
|
||||
@doc "Returns the default page definition for the given slug."
|
||||
def for_slug(slug), do: %{slug: slug, title: title(slug), blocks: blocks(slug)}
|
||||
|
||||
@doc "Returns default definitions for all pages."
|
||||
def all do
|
||||
Berrypod.Pages.Page.valid_slugs()
|
||||
|> Enum.map(&for_slug/1)
|
||||
end
|
||||
|
||||
# ── Page titles ─────────────────────────────────────────────────
|
||||
|
||||
defp title("home"), do: "Home page"
|
||||
defp title("about"), do: "About"
|
||||
defp title("delivery"), do: "Delivery"
|
||||
defp title("privacy"), do: "Privacy policy"
|
||||
defp title("terms"), do: "Terms & conditions"
|
||||
defp title("contact"), do: "Contact"
|
||||
defp title("collection"), do: "Collection"
|
||||
defp title("pdp"), do: "Product page"
|
||||
defp title("cart"), do: "Cart"
|
||||
defp title("search"), do: "Search"
|
||||
defp title("checkout_success"), do: "Checkout success"
|
||||
defp title("orders"), do: "Orders"
|
||||
defp title("order_detail"), do: "Order detail"
|
||||
defp title("error"), do: "Error"
|
||||
|
||||
# ── Block lists ─────────────────────────────────────────────────
|
||||
|
||||
defp blocks("home") do
|
||||
[
|
||||
block("hero", %{
|
||||
"title" => "Original designs, printed on demand",
|
||||
"description" =>
|
||||
"Welcome to the Berrypod demo store. This is where your hero text goes \u2013 something short and punchy about what makes your shop worth a browse.",
|
||||
"cta_text" => "Shop the collection",
|
||||
"cta_href" => "/collections/all",
|
||||
"variant" => "default"
|
||||
}),
|
||||
block("category_nav"),
|
||||
block("featured_products", %{
|
||||
"title" => "Featured products",
|
||||
"product_count" => 8
|
||||
}),
|
||||
block("image_text", %{
|
||||
"title" => "Made with passion, printed with care",
|
||||
"description" =>
|
||||
"This is an example content section. Use it to share your story, highlight what makes your products special, or link to your about page.",
|
||||
"image_url" => "/mockups/mountain-sunrise-print-3-800.webp",
|
||||
"link_text" => "Learn more about the studio \u2192",
|
||||
"link_href" => "/about"
|
||||
})
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("about") do
|
||||
[
|
||||
block("hero", %{
|
||||
"title" => "About the studio",
|
||||
"description" => "",
|
||||
"variant" => "sunken"
|
||||
}),
|
||||
block("content_body", %{
|
||||
"image_src" => "/mockups/night-sky-blanket-3",
|
||||
"image_alt" => "Night sky blanket"
|
||||
})
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("delivery") do
|
||||
[
|
||||
block("hero", %{
|
||||
"title" => "Delivery information",
|
||||
"description" => "",
|
||||
"variant" => "page"
|
||||
}),
|
||||
block("content_body")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("privacy") do
|
||||
[
|
||||
block("hero", %{
|
||||
"title" => "Privacy policy",
|
||||
"description" => "",
|
||||
"variant" => "page"
|
||||
}),
|
||||
block("content_body")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("terms") do
|
||||
[
|
||||
block("hero", %{
|
||||
"title" => "Terms & conditions",
|
||||
"description" => "",
|
||||
"variant" => "page"
|
||||
}),
|
||||
block("content_body")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("contact") do
|
||||
[
|
||||
block("hero", %{
|
||||
"title" => "Get in touch",
|
||||
"description" =>
|
||||
"Sample contact page for the demo store. Add your own message here \u2013 something friendly about how customers can reach you.",
|
||||
"variant" => "page"
|
||||
}),
|
||||
block("contact_form", %{"email" => "hello@example.com"}),
|
||||
block("order_tracking_card"),
|
||||
block("info_card", %{
|
||||
"title" => "Handy to know",
|
||||
"items" => [
|
||||
%{"label" => "Printing", "value" => "Example: 2-5 business days"},
|
||||
%{"label" => "Delivery", "value" => "Example: 3-7 business days after printing"},
|
||||
%{"label" => "Issues", "value" => "Example: Reprints for any defects"}
|
||||
]
|
||||
}),
|
||||
block("newsletter_card"),
|
||||
block("social_links_card")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("collection") do
|
||||
[
|
||||
block("collection_header"),
|
||||
block("filter_bar"),
|
||||
block("product_grid")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("pdp") do
|
||||
[
|
||||
block("breadcrumb"),
|
||||
block("product_hero"),
|
||||
block("trust_badges"),
|
||||
block("product_details"),
|
||||
block("reviews_section"),
|
||||
block("related_products")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("cart") do
|
||||
[
|
||||
block("cart_items"),
|
||||
block("order_summary")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("search") do
|
||||
[
|
||||
block("search_results")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("checkout_success") do
|
||||
[
|
||||
block("checkout_result")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("orders") do
|
||||
[
|
||||
block("order_card")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("order_detail") do
|
||||
[
|
||||
block("order_detail_card")
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks("error") do
|
||||
[
|
||||
block("hero", %{
|
||||
"variant" => "error",
|
||||
"cta_text" => "Go to Homepage",
|
||||
"cta_href" => "/"
|
||||
}),
|
||||
block("featured_products", %{
|
||||
"title" => "Featured products",
|
||||
"product_count" => 4
|
||||
})
|
||||
]
|
||||
end
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
defp block(type, settings \\ %{}) do
|
||||
%{
|
||||
"id" => generate_block_id(),
|
||||
"type" => type,
|
||||
"settings" => settings
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Generates a unique block ID."
|
||||
def generate_block_id do
|
||||
"blk_" <> (:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false))
|
||||
end
|
||||
end
|
||||
31
lib/berrypod/pages/page.ex
Normal file
31
lib/berrypod/pages/page.ex
Normal file
@ -0,0 +1,31 @@
|
||||
defmodule Berrypod.Pages.Page do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@valid_slugs ~w(
|
||||
home about delivery privacy terms contact
|
||||
collection pdp cart search
|
||||
checkout_success orders order_detail error
|
||||
)
|
||||
|
||||
schema "pages" do
|
||||
field :slug, :string
|
||||
field :title, :string
|
||||
field :blocks, {:array, :map}, default: []
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(page, attrs) do
|
||||
page
|
||||
|> cast(attrs, [:slug, :title, :blocks])
|
||||
|> validate_required([:slug, :title, :blocks])
|
||||
|> validate_inclusion(:slug, @valid_slugs)
|
||||
|> unique_constraint(:slug)
|
||||
end
|
||||
|
||||
def valid_slugs, do: @valid_slugs
|
||||
end
|
||||
86
lib/berrypod/pages/page_cache.ex
Normal file
86
lib/berrypod/pages/page_cache.ex
Normal file
@ -0,0 +1,86 @@
|
||||
defmodule Berrypod.Pages.PageCache do
|
||||
@moduledoc """
|
||||
GenServer that maintains an ETS table for caching page definitions.
|
||||
|
||||
Same pattern as `Berrypod.Theme.CSSCache`. Pages are cached by slug
|
||||
for O(1) lookups. Invalidated on save, warmed on startup.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
@table_name :page_cache
|
||||
|
||||
## Client API
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Gets a cached page by slug. Returns `{:ok, page_data}` or `:miss`."
|
||||
def get(slug) do
|
||||
case :ets.lookup(@table_name, slug) do
|
||||
[{^slug, page_data}] -> {:ok, page_data}
|
||||
[] -> :miss
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Caches a page definition by slug."
|
||||
def put(slug, page_data) do
|
||||
:ets.insert(@table_name, {slug, page_data})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc "Invalidates a single cached page."
|
||||
def invalidate(slug) do
|
||||
:ets.delete(@table_name, slug)
|
||||
:ok
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
end
|
||||
|
||||
@doc "Invalidates all cached pages."
|
||||
def invalidate_all do
|
||||
:ets.delete_all_objects(@table_name)
|
||||
:ok
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
end
|
||||
|
||||
@doc "Warms the cache by loading all pages from the DB."
|
||||
def warm do
|
||||
alias Berrypod.Pages
|
||||
|
||||
for slug <- Berrypod.Pages.Page.valid_slugs() do
|
||||
page = Pages.get_page_from_db(slug)
|
||||
put(slug, page)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
## Server callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
:ets.new(@table_name, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: false
|
||||
])
|
||||
|
||||
{:ok, %{}, {:continue, :warm}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue(:warm, state) do
|
||||
try do
|
||||
warm()
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
16
priv/repo/migrations/20260226100000_create_pages.exs
Normal file
16
priv/repo/migrations/20260226100000_create_pages.exs
Normal file
@ -0,0 +1,16 @@
|
||||
defmodule Berrypod.Repo.Migrations.CreatePages do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:pages, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :slug, :string, null: false
|
||||
add :title, :string, null: false
|
||||
add :blocks, :map, default: "[]"
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:pages, [:slug])
|
||||
end
|
||||
end
|
||||
341
test/berrypod/pages_test.exs
Normal file
341
test/berrypod/pages_test.exs
Normal file
@ -0,0 +1,341 @@
|
||||
defmodule Berrypod.PagesTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
|
||||
|
||||
describe "get_page/1" do
|
||||
test "returns defaults when nothing in DB" do
|
||||
page = Pages.get_page("home")
|
||||
|
||||
assert page.slug == "home"
|
||||
assert page.title == "Home page"
|
||||
assert is_list(page.blocks)
|
||||
assert length(page.blocks) == 4
|
||||
|
||||
types = Enum.map(page.blocks, & &1["type"])
|
||||
assert types == ["hero", "category_nav", "featured_products", "image_text"]
|
||||
end
|
||||
|
||||
test "returns DB version when page has been saved" do
|
||||
{:ok, _} =
|
||||
Pages.save_page("home", %{
|
||||
title: "Custom home",
|
||||
blocks: [%{"id" => "blk_test", "type" => "hero", "settings" => %{}}]
|
||||
})
|
||||
|
||||
page = Pages.get_page("home")
|
||||
|
||||
assert page.slug == "home"
|
||||
assert page.title == "Custom home"
|
||||
assert length(page.blocks) == 1
|
||||
end
|
||||
|
||||
test "each block has an id, type, and settings" do
|
||||
page = Pages.get_page("contact")
|
||||
|
||||
for block <- page.blocks do
|
||||
assert is_binary(block["id"]), "block missing id"
|
||||
assert is_binary(block["type"]), "block missing type"
|
||||
assert is_map(block["settings"]), "block missing settings"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_pages/0" do
|
||||
test "returns all 14 pages" do
|
||||
pages = Pages.list_pages()
|
||||
|
||||
assert length(pages) == 14
|
||||
slugs = Enum.map(pages, & &1.slug)
|
||||
assert "home" in slugs
|
||||
assert "pdp" in slugs
|
||||
assert "error" in slugs
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_page/2" do
|
||||
test "creates a new page in the DB" do
|
||||
blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
|
||||
|
||||
assert {:ok, page} = Pages.save_page("about", %{title: "About us", blocks: blocks})
|
||||
|
||||
assert page.slug == "about"
|
||||
assert page.title == "About us"
|
||||
assert length(page.blocks) == 1
|
||||
end
|
||||
|
||||
test "updates an existing page" do
|
||||
{:ok, _} = Pages.save_page("about", %{title: "About v1", blocks: []})
|
||||
{:ok, page} = Pages.save_page("about", %{title: "About v2", blocks: []})
|
||||
|
||||
assert page.title == "About v2"
|
||||
assert Repo.aggregate(from(p in Page, where: p.slug == "about"), :count) == 1
|
||||
end
|
||||
|
||||
test "invalidates cache on save" do
|
||||
# Prime the cache
|
||||
_page = Pages.get_page("home")
|
||||
|
||||
# Save a new version
|
||||
{:ok, _} = Pages.save_page("home", %{title: "New home", blocks: []})
|
||||
|
||||
# Should return the new version
|
||||
page = Pages.get_page("home")
|
||||
assert page.title == "New home"
|
||||
end
|
||||
|
||||
test "rejects invalid slug" do
|
||||
assert {:error, changeset} =
|
||||
Pages.save_page("nonexistent", %{title: "Bad", blocks: []})
|
||||
|
||||
assert errors_on(changeset).slug
|
||||
end
|
||||
end
|
||||
|
||||
describe "reset_page/1" do
|
||||
test "restores defaults after customisation" do
|
||||
{:ok, _} =
|
||||
Pages.save_page("home", %{
|
||||
title: "Custom",
|
||||
blocks: [%{"id" => "blk_1", "type" => "hero", "settings" => %{}}]
|
||||
})
|
||||
|
||||
assert :ok = Pages.reset_page("home")
|
||||
|
||||
page = Pages.get_page("home")
|
||||
assert page.title == "Home page"
|
||||
assert length(page.blocks) == 4
|
||||
end
|
||||
|
||||
test "is a no-op for a page that was never saved" do
|
||||
assert :ok = Pages.reset_page("cart")
|
||||
end
|
||||
end
|
||||
|
||||
describe "load_block_data/2" do
|
||||
test "returns empty map when no blocks have data loaders" do
|
||||
blocks = [
|
||||
%{"type" => "hero", "settings" => %{}},
|
||||
%{"type" => "category_nav", "settings" => %{}}
|
||||
]
|
||||
|
||||
assert Pages.load_block_data(blocks, %{}) == %{}
|
||||
end
|
||||
|
||||
test "loads review data when reviews_section block is present" do
|
||||
blocks = [%{"type" => "reviews_section", "settings" => %{}}]
|
||||
data = Pages.load_block_data(blocks, %{})
|
||||
|
||||
assert is_list(data.reviews)
|
||||
assert data.average_rating == 5
|
||||
assert data.total_count == 24
|
||||
end
|
||||
|
||||
test "loads featured products with configurable count" do
|
||||
blocks = [%{"type" => "featured_products", "settings" => %{"product_count" => 4}}]
|
||||
data = Pages.load_block_data(blocks, %{mode: :preview})
|
||||
|
||||
assert is_list(data.products)
|
||||
end
|
||||
|
||||
test "loads related products in preview mode" do
|
||||
blocks = [%{"type" => "related_products", "settings" => %{}}]
|
||||
data = Pages.load_block_data(blocks, %{mode: :preview})
|
||||
|
||||
assert is_list(data.related_products)
|
||||
end
|
||||
|
||||
test "loads related products from DB with product context" do
|
||||
blocks = [%{"type" => "related_products", "settings" => %{}}]
|
||||
data = Pages.load_block_data(blocks, %{product: %{category: "Prints", id: "fake-id"}})
|
||||
|
||||
assert is_list(data.related_products)
|
||||
end
|
||||
|
||||
test "merges data from multiple loaders" do
|
||||
blocks = [
|
||||
%{"type" => "featured_products", "settings" => %{}},
|
||||
%{"type" => "reviews_section", "settings" => %{}}
|
||||
]
|
||||
|
||||
data = Pages.load_block_data(blocks, %{mode: :preview})
|
||||
|
||||
assert Map.has_key?(data, :products)
|
||||
assert Map.has_key?(data, :reviews)
|
||||
end
|
||||
end
|
||||
|
||||
describe "BlockTypes" do
|
||||
test "all/0 returns all block types" do
|
||||
types = BlockTypes.all()
|
||||
|
||||
assert is_map(types)
|
||||
assert map_size(types) > 20
|
||||
assert Map.has_key?(types, "hero")
|
||||
assert Map.has_key?(types, "product_hero")
|
||||
assert Map.has_key?(types, "search_results")
|
||||
end
|
||||
|
||||
test "get/1 returns a block type definition" do
|
||||
hero = BlockTypes.get("hero")
|
||||
|
||||
assert hero.name == "Hero banner"
|
||||
assert hero.icon == "hero-megaphone"
|
||||
assert hero.allowed_on == :all
|
||||
assert is_list(hero.settings_schema)
|
||||
end
|
||||
|
||||
test "get/1 returns nil for unknown type" do
|
||||
assert BlockTypes.get("nonexistent") == nil
|
||||
end
|
||||
|
||||
test "allowed_for/1 returns portable + page-specific blocks" do
|
||||
pdp_types = BlockTypes.allowed_for("pdp")
|
||||
|
||||
# Portable blocks are included
|
||||
assert Map.has_key?(pdp_types, "hero")
|
||||
assert Map.has_key?(pdp_types, "featured_products")
|
||||
|
||||
# PDP-specific blocks are included
|
||||
assert Map.has_key?(pdp_types, "product_hero")
|
||||
assert Map.has_key?(pdp_types, "breadcrumb")
|
||||
|
||||
# Other page-specific blocks are excluded
|
||||
refute Map.has_key?(pdp_types, "cart_items")
|
||||
refute Map.has_key?(pdp_types, "contact_form")
|
||||
end
|
||||
|
||||
test "allowed_for/1 includes only relevant page-specific blocks" do
|
||||
cart_types = BlockTypes.allowed_for("cart")
|
||||
|
||||
assert Map.has_key?(cart_types, "cart_items")
|
||||
assert Map.has_key?(cart_types, "order_summary")
|
||||
refute Map.has_key?(cart_types, "product_hero")
|
||||
end
|
||||
|
||||
test "every block type has required fields" do
|
||||
for {type, def} <- BlockTypes.all() do
|
||||
assert is_binary(def.name), "#{type} missing name"
|
||||
assert is_binary(def.icon), "#{type} missing icon"
|
||||
assert def.allowed_on == :all or is_list(def.allowed_on), "#{type} bad allowed_on"
|
||||
assert is_list(def.settings_schema), "#{type} missing settings_schema"
|
||||
end
|
||||
end
|
||||
|
||||
test "settings_schema entries have required fields" do
|
||||
for {type, def} <- BlockTypes.all(), setting <- def.settings_schema do
|
||||
assert is_binary(setting.key), "#{type} setting missing key"
|
||||
assert is_binary(setting.label), "#{type} setting missing label"
|
||||
assert is_atom(setting.type), "#{type} setting missing type"
|
||||
assert Map.has_key?(setting, :default), "#{type} setting #{setting.key} missing default"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Defaults" do
|
||||
test "all/0 returns 14 page definitions" do
|
||||
all = Defaults.all()
|
||||
assert length(all) == 14
|
||||
end
|
||||
|
||||
test "every default page has blocks matching registered types" do
|
||||
for page <- Defaults.all() do
|
||||
assert is_binary(page.slug), "page missing slug"
|
||||
assert is_binary(page.title), "page missing title"
|
||||
assert is_list(page.blocks), "#{page.slug} blocks is not a list"
|
||||
|
||||
for block <- page.blocks do
|
||||
assert BlockTypes.get(block["type"]),
|
||||
"#{page.slug} has unknown block type: #{block["type"]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "every default block type is allowed on its page" do
|
||||
for page <- Defaults.all() do
|
||||
allowed = BlockTypes.allowed_for(page.slug)
|
||||
|
||||
for block <- page.blocks do
|
||||
assert Map.has_key?(allowed, block["type"]),
|
||||
"#{block["type"]} not allowed on #{page.slug}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "block IDs are unique within each page" do
|
||||
for page <- Defaults.all() do
|
||||
ids = Enum.map(page.blocks, & &1["id"])
|
||||
assert ids == Enum.uniq(ids), "duplicate block IDs on #{page.slug}"
|
||||
end
|
||||
end
|
||||
|
||||
test "home page has the expected blocks" do
|
||||
page = Defaults.for_slug("home")
|
||||
|
||||
types = Enum.map(page.blocks, & &1["type"])
|
||||
assert types == ["hero", "category_nav", "featured_products", "image_text"]
|
||||
end
|
||||
|
||||
test "pdp page has the expected blocks" do
|
||||
page = Defaults.for_slug("pdp")
|
||||
|
||||
types = Enum.map(page.blocks, & &1["type"])
|
||||
|
||||
assert types == [
|
||||
"breadcrumb",
|
||||
"product_hero",
|
||||
"trust_badges",
|
||||
"product_details",
|
||||
"reviews_section",
|
||||
"related_products"
|
||||
]
|
||||
end
|
||||
|
||||
test "contact page has the expected blocks" do
|
||||
page = Defaults.for_slug("contact")
|
||||
|
||||
types = Enum.map(page.blocks, & &1["type"])
|
||||
|
||||
assert types == [
|
||||
"hero",
|
||||
"contact_form",
|
||||
"order_tracking_card",
|
||||
"info_card",
|
||||
"newsletter_card",
|
||||
"social_links_card"
|
||||
]
|
||||
end
|
||||
|
||||
test "generate_block_id/0 produces unique prefixed IDs" do
|
||||
id1 = Defaults.generate_block_id()
|
||||
id2 = Defaults.generate_block_id()
|
||||
|
||||
assert String.starts_with?(id1, "blk_")
|
||||
assert String.starts_with?(id2, "blk_")
|
||||
assert id1 != id2
|
||||
end
|
||||
end
|
||||
|
||||
describe "PageCache" do
|
||||
test "get/1 returns :miss for uncached slug" do
|
||||
assert :miss = PageCache.get("nonexistent_slug_#{System.unique_integer()}")
|
||||
end
|
||||
|
||||
test "put/2 and get/1 round-trip" do
|
||||
slug = "test_cache_#{System.unique_integer()}"
|
||||
page_data = %{slug: slug, title: "Test", blocks: []}
|
||||
|
||||
assert :ok = PageCache.put(slug, page_data)
|
||||
assert {:ok, ^page_data} = PageCache.get(slug)
|
||||
end
|
||||
|
||||
test "invalidate/1 removes cached entry" do
|
||||
slug = "test_invalidate_#{System.unique_integer()}"
|
||||
PageCache.put(slug, %{slug: slug, title: "Test", blocks: []})
|
||||
|
||||
assert :ok = PageCache.invalidate(slug)
|
||||
assert :miss = PageCache.get(slug)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user