diff --git a/dev.exs b/dev.exs index 5c8fe641..055b07a8 100644 --- a/dev.exs +++ b/dev.exs @@ -17,12 +17,15 @@ Logger.configure(level: :debug) Application.put_env(:phoenix, :json_library, Jason) +display_error_pages? = false + Application.put_env(:beacon, SamplePhoenix.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4001], server: true, live_view: [signing_salt: "aaaaaaaa"], secret_key_base: String.duplicate("a", 64), - debug_errors: true, + debug_errors: !display_error_pages?, + render_errors: [formats: [html: BeaconWeb.ErrorHTML]], check_origin: false, pubsub_server: SamplePhoenix.PubSub, live_reload: [ diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index ed594225..0d76dd5e 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -26,6 +26,7 @@ defmodule Beacon.Content do import Ecto.Query alias Beacon.Content.Component + alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.LayoutEvent alias Beacon.Content.LayoutSnapshot @@ -47,7 +48,7 @@ defmodule Beacon.Content do @doc """ Returns the list of meta tags that are applied to all pages by default. - These meta tags can be overwriten or extended on a Layout or Page level. + These meta tags can be overwritten or extended on a Layout or Page level. """ @spec default_site_meta_tags() :: [map()] def default_site_meta_tags do @@ -73,6 +74,18 @@ defmodule Beacon.Content do Layout.changeset(layout, attrs) end + @doc """ + Returns a map of attrs to load the default layout into new sites. + """ + @spec default_layout() :: map() + @doc type: :layouts + def default_layout do + %{ + title: "Default", + template: "<%= @inner_content %>" + } + end + @doc """ Creates a layout. @@ -1675,7 +1688,7 @@ defmodule Beacon.Content do Note that the `:page` assigns is made available as `assigns["page"]` (String.t) due to how Solid works. - Snipets can be used in: + Snippets can be used in: * Meta Tag value * Page Schema (structured Schema.org tags) @@ -1704,6 +1717,137 @@ defmodule Beacon.Content do end end + # ERROR PAGES + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking error page changes. + + ## Example + + iex> change_error_page(error_page, %{status: 404}) + %Ecto.Changeset{data: %ErrorPage{}} + + """ + @doc type: :error_pages + @spec change_error_page(ErrorPage.t(), map()) :: Changeset.t() + def change_error_page(%ErrorPage{} = error_page, attrs \\ %{}) do + ErrorPage.changeset(error_page, attrs) + end + + @doc """ + Returns the error page for a given site and status code, or `nil` if no matching error page exists. + """ + @doc type: :error_pages + @spec get_error_page(Site.t(), ErrorPage.error_status()) :: ErrorPage.t() | nil + def get_error_page(site, status) do + Repo.one( + from e in ErrorPage, + where: e.site == ^site, + where: e.status == ^status + ) + end + + @doc """ + Lists all error pages for a given site. + + ## Options + + * `:per_page` - limit how many records are returned, or pass `:infinity` to return all records. + * `:preloads` - a list of preloads to load. + + """ + @doc type: :error_pages + @spec list_error_pages(Site.t(), keyword()) :: [ErrorPage.t()] + def list_error_pages(site, opts \\ []) do + per_page = Keyword.get(opts, :per_page, 20) + preloads = Keyword.get(opts, :preloads, []) + + site + |> query_list_error_pages_base() + |> query_list_error_pages_limit(per_page) + |> query_list_error_pages_preloads(preloads) + |> Repo.all() + end + + defp query_list_error_pages_base(site) do + from p in ErrorPage, + where: p.site == ^site, + order_by: [asc: p.status] + end + + defp query_list_error_pages_limit(query, limit) when is_integer(limit), do: from(q in query, limit: ^limit) + defp query_list_error_pages_limit(query, :infinity = _limit), do: query + defp query_list_error_pages_limit(query, _per_page), do: from(q in query, limit: 20) + + defp query_list_error_pages_preloads(query, [_preload | _] = preloads) do + from(q in query, preload: ^preloads) + end + + defp query_list_error_pages_preloads(query, _preloads), do: query + + @doc """ + Creates a new error page. + """ + @doc type: :error_pages + @spec create_error_page(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}) :: + {:ok, ErrorPage.t()} | {:error, Changeset.t()} + def create_error_page(attrs) do + %ErrorPage{} + |> ErrorPage.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a new error page, raising if the operation fails. + """ + @doc type: :error_pages + @spec create_error_page!(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}) :: + ErrorPage.t() + def create_error_page!(attrs) do + case create_error_page(attrs) do + {:ok, error_page} -> error_page + {:error, changeset} -> raise "failed to create error page, got: #{inspect(changeset.errors)}" + end + end + + @doc """ + Returns attr data to load the default error_pages into new sites. + """ + @spec default_error_pages() :: [map()] + @doc type: :error_pages + def default_error_pages do + for status <- [404, 500] do + %{ + status: status, + template: Plug.Conn.Status.reason_phrase(status) + } + end + end + + @doc """ + Updates an error page. + """ + @doc type: :error_pages + @spec update_error_page(ErrorPage.t(), map()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t()} + def update_error_page(error_page, attrs) do + error_page + |> ErrorPage.changeset(attrs) + |> Repo.update() + |> tap(&maybe_reload_error_page/1) + end + + def maybe_reload_error_page({:ok, error_page}), do: PubSub.error_page_updated(error_page) + def maybe_reload_error_page({:error, _error_page}), do: :noop + + @doc """ + Deletes an error page. + """ + @doc type: :error_pages + @spec delete_error_page(ErrorPage.t()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t()} + def delete_error_page(error_page) do + Repo.delete(error_page) + end + # PAGE EVENT HANDLERS @doc """ diff --git a/lib/beacon/content/error_page.ex b/lib/beacon/content/error_page.ex new file mode 100644 index 00000000..a36081d5 --- /dev/null +++ b/lib/beacon/content/error_page.ex @@ -0,0 +1,68 @@ +defmodule Beacon.Content.ErrorPage do + @moduledoc """ + Stores a template which can be rendered for error responses. + + An ErrorPage contains four main fields: + + * `:status` - the status code for which this ErrorPage is to be used + * `:template` - the template to be rendered + * `:site` - the Beacon site which should use this page + * `:layout_id` - the ID of the Beacon Layout which is used for rendering + + > #### Do not create or edit ErrorPages manually {: .warning} + > + > Use the public functions in `Beacon.Content` instead. + > The functions in that module guarantee that all dependencies + > are created correctly and all processes are updated. + > Manipulating data manually will most likely result + > in inconsistent behavior and crashes. + + """ + use Beacon.Schema + + import Beacon.Utils, only: [list_to_typespec: 1] + import Ecto.Changeset + + # We can use Range.to_list/1 here when we upgrade to Elixir 1.15 + @valid_error_statuses Enum.to_list(400..418) ++ + Enum.to_list(421..426) ++ + [428, 429, 431, 451] ++ + Enum.to_list(500..508) ++ + [510, 511] + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + site: Beacon.Types.Site.t(), + status: error_status(), + template: binary(), + layout_id: Ecto.UUID.t(), + layout: Beacon.Content.Layout.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @type error_status :: unquote(list_to_typespec(@valid_error_statuses)) + + schema "beacon_error_pages" do + field :site, Beacon.Types.Site + field :status, :integer + field :template, :string + + belongs_to :layout, Beacon.Content.Layout + + timestamps() + end + + @doc false + def changeset(%__MODULE__{} = error_page, attrs) do + fields = ~w(status template site layout_id)a + + error_page + |> cast(attrs, fields) + |> validate_required(fields) + |> unique_constraint([:status, :site]) + |> validate_inclusion(:status, @valid_error_statuses) + end + + def valid_statuses, do: @valid_error_statuses +end diff --git a/lib/beacon/content/layout.ex b/lib/beacon/content/layout.ex index 9bcedca6..e229f9f1 100644 --- a/lib/beacon/content/layout.ex +++ b/lib/beacon/content/layout.ex @@ -49,4 +49,7 @@ defmodule Beacon.Content.Layout do |> cast(attrs, [:site, :title, :template, :meta_tags, :resource_links]) |> validate_required([:site, :title]) end + + @doc false + def fetch(layout, key), do: Map.fetch(layout, key) end diff --git a/lib/beacon/loader.ex b/lib/beacon/loader.ex index 4d5b286c..c95ddd49 100644 --- a/lib/beacon/loader.ex +++ b/lib/beacon/loader.ex @@ -1,6 +1,6 @@ defmodule Beacon.Loader do @moduledoc """ - Loader is the process resposible for loading, unloading, and reloading all resources for each site. + Loader is the process responsible for loading, unloading, and reloading all resources for each site. At start it will load all `Beacon.Content.blueprint_components/0` and existing resources stored in the database like layouts, pages, snippets, etc. @@ -15,6 +15,7 @@ defmodule Beacon.Loader do alias Beacon.Content alias Beacon.Loader.ComponentModuleLoader + alias Beacon.Loader.ErrorPageModuleLoader alias Beacon.Loader.LayoutModuleLoader alias Beacon.Loader.PageModuleLoader alias Beacon.Loader.SnippetModuleLoader @@ -34,23 +35,43 @@ defmodule Beacon.Loader do Beacon.Registry.via({site, __MODULE__}) end - @doc false - def init(config) do - if Code.ensure_loaded?(Mix.Project) and Mix.env() == :test do - :skip - else - with :ok <- populate_components(config.site) do - :ok = load_site_from_db(config.site) - end + if Code.ensure_loaded?(Mix.Project) and Mix.env() == :test do + @doc false + def init(config) do + %{site: site} = config + + # avoid compilation warnings + populate_components(nil) + + PubSub.subscribe_to_layouts(site) + PubSub.subscribe_to_pages(site) + PubSub.subscribe_to_components(site) + PubSub.subscribe_to_error_pages(site) + + {:ok, config} end + else + @doc false + def init(config) do + %{site: site} = config + + with :ok <- populate_components(site), + :ok <- populate_layouts(site), + :ok <- populate_error_pages(site) do + :ok = load_site_from_db(site) + end - PubSub.subscribe_to_layouts(config.site) - PubSub.subscribe_to_pages(config.site) - PubSub.subscribe_to_components(config.site) + PubSub.subscribe_to_layouts(site) + PubSub.subscribe_to_pages(site) + PubSub.subscribe_to_components(site) + PubSub.subscribe_to_error_pages(site) - {:ok, config} + {:ok, config} + end end + defp populate_components(nil), do: :skip + defp populate_components(site) do for attrs <- Content.blueprint_components() do case Content.list_components_by_name(site, attrs.name) do @@ -67,6 +88,42 @@ defmodule Beacon.Loader do :ok end + @doc false + def populate_layouts(site) do + case Content.get_layout_by(site, title: "Default") do + nil -> + Content.default_layout() + |> Map.put(:site, site) + |> Content.create_layout!() + |> Content.publish_layout() + + _ -> + :skip + end + + :ok + end + + @doc false + def populate_error_pages(site) do + default_layout = Content.get_layout_by(site, title: "Default") + + for attrs <- Content.default_error_pages() do + case Content.get_error_page(site, attrs.status) do + nil -> + attrs + |> Map.put(:site, site) + |> Map.put(:layout_id, default_layout.id) + |> Content.create_error_page!() + + _ -> + :skip + end + end + + :ok + end + defp load_site_from_db(site) do with :ok <- Beacon.RuntimeJS.load!(), :ok <- load_runtime_css(site), @@ -74,7 +131,8 @@ defmodule Beacon.Loader do :ok <- load_components(site), :ok <- load_snippet_helpers(site), :ok <- load_layouts(site), - :ok <- load_pages(site) do + :ok <- load_pages(site), + :ok <- load_error_pages(site) do :ok else _ -> raise Beacon.LoaderError, message: "failed to load resources for site #{site}" @@ -184,6 +242,12 @@ defmodule Beacon.Loader do :ok end + defp load_error_pages(site) do + error_pages = Content.list_error_pages(site, preloads: [:layout]) + ErrorPageModuleLoader.load_error_pages!(error_pages, site) + :ok + end + @doc false def stylesheet_module_for_site(site) do module_for_site(site, "Stylesheet") @@ -194,6 +258,11 @@ defmodule Beacon.Loader do module_for_site(site, "Component") end + @doc false + def error_module_for_site(site) do + module_for_site(site, "ErrorPages") + end + @doc false def snippet_helpers_module_for_site(site) do module_for_site(site, "SnippetHelpers") @@ -356,6 +425,15 @@ defmodule Beacon.Loader do def handle_info({:component_updated, component}, state) do :ok = load_components(component.site) :ok = Beacon.PubSub.component_loaded(component) + :ok = load_runtime_css(component.site) + {:noreply, state} + end + + @doc false + def handle_info({:error_page_updated, error_page}, state) do + :ok = load_error_pages(error_page.site) + :ok = Beacon.PubSub.error_page_loaded(error_page) + :ok = load_runtime_css(error_page.site) {:noreply, state} end diff --git a/lib/beacon/loader/error_page_module_loader.ex b/lib/beacon/loader/error_page_module_loader.ex new file mode 100644 index 00000000..009d4cde --- /dev/null +++ b/lib/beacon/loader/error_page_module_loader.ex @@ -0,0 +1,67 @@ +defmodule Beacon.Loader.ErrorPageModuleLoader do + @moduledoc false + alias Beacon.Content + alias Beacon.Content.ErrorPage + alias Beacon.Loader + + def load_error_pages!(error_pages, site) do + error_module = Loader.error_module_for_site(site) + layout_functions = Enum.map(error_pages, &build_layout_fn/1) + render_functions = Enum.map(error_pages, &build_render_fn(&1, error_module)) + + ast = + quote do + defmodule unquote(error_module) do + use Phoenix.HTML + require EEx + import Phoenix.Component + require Logger + + unquote_splicing(layout_functions) + unquote_splicing(render_functions) + + # catch-all for error which do not have an ErrorPage defined + def render(var!(conn), var!(status)) do + _ = var!(conn) + Logger.warning("missing error page for #{unquote(site)} status #{var!(status)}") + Plug.Conn.Status.reason_phrase(var!(status)) + end + + EEx.function_from_file( + :def, + :root_layout, + Path.join([:code.priv_dir(:beacon), "layouts", "runtime_error.html.heex"]), + [:assigns], + engine: Phoenix.HTML.Engine + ) + end + end + + :ok = Loader.reload_module!(error_module, ast) + {:ok, error_module, ast} + end + + defp build_layout_fn(%ErrorPage{} = error_page) do + %{site: site, layout: %{id: layout_id}, status: status} = error_page + %{template: template} = Content.get_published_layout(site, layout_id) + compiled = EEx.compile_string(template, engine: Phoenix.HTML.Engine) + + quote do + def layout(unquote(status), var!(assigns)) when is_map(var!(assigns)) do + unquote(compiled) + end + end + end + + defp build_render_fn(%ErrorPage{} = error_page, error_module) do + %{template: template, status: status} = error_page + compiled = EEx.compile_string(template, engine: Phoenix.HTML.Engine) + + quote do + def render(var!(conn), unquote(status)) do + var!(assigns) = %{conn: var!(conn), inner_content: unquote(error_module).layout(unquote(status), %{inner_content: unquote(compiled)})} + unquote(error_module).root_layout(var!(assigns)) + end + end + end +end diff --git a/lib/beacon/pub_sub.ex b/lib/beacon/pub_sub.ex index 32661ae3..1f1dca28 100644 --- a/lib/beacon/pub_sub.ex +++ b/lib/beacon/pub_sub.ex @@ -3,6 +3,8 @@ defmodule Beacon.PubSub do require Logger alias Beacon.Content.Component + alias Beacon.Content.ErrorPage + alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.Page @@ -128,6 +130,36 @@ defmodule Beacon.PubSub do defp component(component), do: %{site: component.site, id: component.id, name: component.name} + # Error Pages + + defp topic_error_pages(site), do: "beacon:#{site}:error_pages" + + defp topic_error_page(site, id) when is_binary(id) do + "beacon:#{site}:error_pages:#{id}" + end + + def subscribe_to_error_pages(site) do + Phoenix.PubSub.subscribe(@pubsub, topic_error_pages(site)) + end + + def subscribe_to_error_page(site, id) do + Phoenix.PubSub.subscribe(@pubsub, topic_error_page(site, id)) + end + + def error_page_updated(%ErrorPage{} = error_page) do + error_page.site + |> topic_error_pages() + |> broadcast({:error_page_updated, error_page(error_page)}) + end + + def error_page_loaded(error_page) do + error_page.site + |> topic_error_page(error_page.id) + |> local_broadcast({:error_page_loaded, error_page(error_page)}) + end + + defp error_page(error_page), do: %{site: error_page.site, id: error_page.id, status: error_page.status} + # Utils defp broadcast(topic, message) when is_binary(topic) do diff --git a/lib/beacon/tailwind_compiler.ex b/lib/beacon/tailwind_compiler.ex index 16057641..833e40cb 100644 --- a/lib/beacon/tailwind_compiler.ex +++ b/lib/beacon/tailwind_compiler.ex @@ -164,6 +164,13 @@ defmodule Beacon.TailwindCompiler do File.write!(page_path, page.template) page_path end) + end), + Task.async(fn -> + Enum.map(Content.list_error_pages(site, per_page: :infinity), fn error_page -> + error_page_path = Path.join(tmp_dir, "#{site}_error_page_#{error_page.status}.template") + File.write!(error_page_path, error_page.template) + error_page_path + end) end) ] |> Task.await_many() diff --git a/lib/beacon/template/heex.ex b/lib/beacon/template/heex.ex index b3e3e0f5..66aa6395 100644 --- a/lib/beacon/template/heex.ex +++ b/lib/beacon/template/heex.ex @@ -36,16 +36,19 @@ defmodule Beacon.Template.HEEx do @doc false def compile_heex_template!(file, template) do - EEx.compile_string(template, - engine: Phoenix.LiveView.TagEngine, - line: 1, - indentation: 0, - file: file, - caller: __ENV__, - source: template, - trim: true, - tag_handler: Phoenix.LiveView.HTMLEngine - ) + opts = + [ + engine: Phoenix.LiveView.TagEngine, + line: 1, + indentation: 0, + file: file, + caller: __ENV__, + source: template, + trim: true, + tag_handler: Phoenix.LiveView.HTMLEngine + ] + + EEx.compile_string(template, opts) end @doc """ diff --git a/lib/beacon/utils.ex b/lib/beacon/utils.ex new file mode 100644 index 00000000..b756e0ee --- /dev/null +++ b/lib/beacon/utils.ex @@ -0,0 +1,8 @@ +defmodule Beacon.Utils do + @moduledoc false + + # https://elixirforum.com/t/dynamically-generate-typespecs-from-module-attribute-list/7078/5 + def list_to_typespec(list) when is_list(list) do + Enum.reduce(list, &{:|, [], [&1, &2]}) + end +end diff --git a/lib/beacon_web/controllers/error_html.ex b/lib/beacon_web/controllers/error_html.ex index ad4d317b..0d84e3a5 100644 --- a/lib/beacon_web/controllers/error_html.ex +++ b/lib/beacon_web/controllers/error_html.ex @@ -2,20 +2,17 @@ defmodule BeaconWeb.ErrorHTML do @moduledoc false use BeaconWeb, :html + require Logger + alias Beacon.Loader - # If you want to customize your error pages, - # uncomment the embed_templates/1 call below - # and add pages to the error directory: - # - # * lib/beacon_web/controllers/error/404.html.heex - # * lib/beacon_web/controllers/error/500.html.heex - # - # embed_templates "error/*" - - # The default is to render a plain text page based on - # the template name. For example, "404.html" becomes - # "Not Found". - def render(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) + def render(<> = template, %{conn: conn}) do + {_, _, %{extra: %{session: %{"beacon_site" => site}}}} = conn.private.phoenix_live_view + error_module = Loader.error_module_for_site(site) + conn = Plug.Conn.assign(conn, :__site__, site) + error_module.render(conn, String.to_integer(status_code)) + rescue + _ -> + Logger.warning("failed to render error page") + Phoenix.Controller.status_message_from_template(template) end end diff --git a/lib/mix/tasks/install.ex b/lib/mix/tasks/install.ex index eedf228f..772da950 100644 --- a/lib/mix/tasks/install.ex +++ b/lib/mix/tasks/install.ex @@ -34,6 +34,7 @@ defmodule Mix.Tasks.Beacon.Install do config_file_path = config_file_path("config.exs") maybe_inject_beacon_repo_into_ecto_repos(config_file_path) + inject_endpoint_render_errors_config(config_file_path) dev_config_file = config_file_path("dev.exs") maybe_inject_beacon_repo_config(dev_config_file, bindings) @@ -91,6 +92,37 @@ defmodule Mix.Tasks.Beacon.Install do end end + @doc false + def inject_endpoint_render_errors_config(config_file_path) do + config_file_content = File.read!(config_file_path) + + if String.contains?(config_file_content, "BeaconWeb.ErrorHTML") do + Mix.shell().info([ + :yellow, + "* skip ", + :reset, + "injecting Beacon.ErrorHTML to render_errors into ", + Path.relative_to_cwd(config_file_path), + " (already exists)" + ]) + else + regex = ~r/(config.*\.Endpoint,\n)((?:.+\n)*\s*)\n/ + render_errors_value = [formats: [html: BeaconWeb.ErrorHTML]] + + [_header, endpoint_config_str] = Regex.run(regex, config_file_content, capture: :all_but_first) + {config_list, []} = Code.eval_string("[" <> endpoint_config_str <> "]") + updated_config_list = Keyword.update(config_list, :render_errors, render_errors_value, fn _ -> render_errors_value end) + updated_str = inspect(updated_config_list) <> "\n" + + new_config_file_content = + regex + |> Regex.replace(config_file_content, "\\1#{updated_str}") + |> Code.format_string!(file: config_file_path) + + File.write!(config_file_path, [new_config_file_content, "\n"]) + end + end + @doc false def maybe_inject_beacon_repo_config(config_file_path, bindings) do config_file_content = File.read!(config_file_path) diff --git a/mix.exs b/mix.exs index 1f3c700a..d950ad46 100644 --- a/mix.exs +++ b/mix.exs @@ -104,6 +104,7 @@ defmodule Beacon.MixProject do Content: [ Beacon.Content, Beacon.Content.Component, + Beacon.Content.ErrorPage, Beacon.Content.Layout, Beacon.Content.LayoutEvent, Beacon.Content.LayoutSnapshot, @@ -181,7 +182,8 @@ defmodule Beacon.MixProject do "Functions: Stylesheets": &(&1[:type] == :stylesheets), "Functions: Components": &(&1[:type] == :components), "Functions: Snippets": &(&1[:type] == :snippets), - "Functions: Page Event Handlers": &(&1[:type] == :page_event_handlers) + "Functions: Page Event Handlers": &(&1[:type] == :page_event_handlers), + "Functions: Error Pages": &(&1[:type] == :error_pages) ] ] end diff --git a/priv/layouts/runtime_error.html.heex b/priv/layouts/runtime_error.html.heex new file mode 100644 index 00000000..2d527f5f --- /dev/null +++ b/priv/layouts/runtime_error.html.heex @@ -0,0 +1,13 @@ + + + + /> + Error + /> + + + + <%= @inner_content %> + + diff --git a/priv/repo/migrations/20230816195326_create_error_pages.exs b/priv/repo/migrations/20230816195326_create_error_pages.exs new file mode 100644 index 00000000..ad28ac03 --- /dev/null +++ b/priv/repo/migrations/20230816195326_create_error_pages.exs @@ -0,0 +1,17 @@ +defmodule Beacon.Repo.Migrations.CreateErrorPages do + use Ecto.Migration + + def change do + create table(:beacon_error_pages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :site, :text, null: false + add :status, :integer, null: false + add :template, :text, null: false + add :layout_id, references(:beacon_layouts, type: :binary_id) + + timestamps() + end + + create unique_index(:beacon_error_pages, [:status, :site]) + end +end diff --git a/test/beacon/content_test.exs b/test/beacon/content_test.exs index a5487275..f5fe581e 100644 --- a/test/beacon/content_test.exs +++ b/test/beacon/content_test.exs @@ -4,6 +4,8 @@ defmodule Beacon.ContentTest do import Beacon.Fixtures alias Beacon.Content + alias Beacon.Content.Component + alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.LayoutEvent alias Beacon.Content.LayoutSnapshot @@ -13,6 +15,7 @@ defmodule Beacon.ContentTest do alias Beacon.Content.PageSnapshot alias Beacon.Content.PageVariant alias Beacon.Repo + alias Ecto.Changeset describe "layouts" do test "create layout should create a created event" do @@ -434,6 +437,41 @@ defmodule Beacon.ContentTest do end end + describe "error_pages:" do + test "get_error_page/2" do + error_page = error_page_fixture(%{site: :my_site, status: 404}) + _other = error_page_fixture(%{site: :my_site, status: 400}) + + assert ^error_page = Content.get_error_page(:my_site, 404) + end + + test "create_error_page/1 OK" do + %{id: layout_id} = layout_fixture() + attrs = %{site: :my_site, status: 400, template: "Oops!", layout_id: layout_id} + + assert {:ok, %ErrorPage{} = error_page} = Content.create_error_page(attrs) + assert %{site: :my_site, status: 400, template: "Oops!", layout_id: ^layout_id} = error_page + end + + test "create_error_page/1 ERROR (duplicate)" do + error_page = error_page_fixture() + bad_attrs = %{site: error_page.site, status: error_page.status, template: "Error", layout_id: layout_fixture().id} + + assert {:error, %Changeset{errors: errors}} = Content.create_error_page(bad_attrs) + assert [{:status, {"has already been taken", [constraint: :unique, constraint_name: "beacon_error_pages_status_site_index"]}}] = errors + end + + test "update_error_page/2" do + error_page = error_page_fixture() + assert {:ok, %ErrorPage{template: "Changed"}} = Content.update_error_page(error_page, %{template: "Changed"}) + end + + test "delete_error_page/1" do + error_page = error_page_fixture() + assert {:ok, %ErrorPage{__meta__: %{state: :deleted}}} = Content.delete_error_page(error_page) + end + end + describe "components" do test "validate template heex on create" do assert {:error, %Ecto.Changeset{errors: [body: {"invalid", [compilation_error: compilation_error]}]}} = @@ -460,5 +498,10 @@ defmodule Beacon.ContentTest do assert Enum.member?(components, component_b) refute Enum.member?(components, component_a) end + + test "update_component" do + component = component_fixture(name: "new_component", body: "old_body") + assert {:ok, %Component{body: "new_body"}} = Content.update_component(component, %{body: "new_body"}) + end end end diff --git a/test/beacon/loader/error_page_module_loader_test.exs b/test/beacon/loader/error_page_module_loader_test.exs new file mode 100644 index 00000000..d64c8d14 --- /dev/null +++ b/test/beacon/loader/error_page_module_loader_test.exs @@ -0,0 +1,143 @@ +defmodule Beacon.Loader.ErrorPageModuleLoaderTest do + use BeaconWeb.ConnCase, async: false + import Beacon.Fixtures + alias Beacon.Loader.ErrorPageModuleLoader + + @site :my_site + + defp load_error_pages_module(site) do + {:ok, module, _ast} = + site + |> Beacon.Content.list_error_pages(preloads: [:layout]) + |> ErrorPageModuleLoader.load_error_pages!(site) + + module + end + + def build_conn(conn) do + conn + |> Plug.Conn.assign(:__site__, @site) + |> Plug.Conn.put_private(:phoenix_router, Beacon.BeaconTest.Router) + end + + setup_all do + start_supervised!({Beacon.Loader, Beacon.Config.fetch!(@site)}) + :ok + end + + setup %{conn: conn} do + :ok = Beacon.Loader.populate_layouts(@site) + :ok = Beacon.Loader.populate_error_pages(@site) + error_module = load_error_pages_module(@site) + + [conn: build_conn(conn), error_module: error_module] + end + + test "root layout", %{conn: conn, error_module: error_module} do + expected = + ~S""" + + + + + Error + + + + + #inner_content# + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.root_layout(%{conn: conn, inner_content: "#inner_content#"}) + assert IO.iodata_to_binary(html) =~ expected + end + + test "default layouts", %{error_module: error_module} do + assert error_module.layout(404, %{inner_content: "Not Found"}) == {:safe, ["Not Found"]} + assert error_module.layout(500, %{inner_content: "Internal Server Error"}) == {:safe, ["Internal Server Error"]} + end + + test "custom layout" do + layout = published_layout_fixture(template: "#custom_layout#<%= @inner_content %>", site: @site) + error_page = error_page_fixture(layout: layout, template: "error_501", status: 501, site: @site) + error_module = load_error_pages_module(@site) + + assert error_module.layout(501, %{inner_content: error_page.template}) == {:safe, ["#custom_layout#", "error_501"]} + end + + test "default error pages", %{conn: conn, error_module: error_module} do + expected = + ~S""" + + + + + Error + + + + + Not Found + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.render(conn, 404) + assert IO.iodata_to_binary(html) =~ expected + + expected = + ~S""" + + + + + Error + + + + + Internal Server Error + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.render(conn, 500) + assert IO.iodata_to_binary(html) =~ expected + end + + test "custom error page", %{conn: conn} do + layout = published_layout_fixture(template: "#custom_layout#<%= @inner_content %>", site: @site) + _error_page = error_page_fixture(layout: layout, template: ~s|error_501|, status: 501, site: @site) + error_module = load_error_pages_module(@site) + + expected = + ~S""" + + + + + Error + + + + + #custom_layout#error_501 + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.render(conn, 501) + + assert IO.iodata_to_binary(html) =~ expected + end +end diff --git a/test/beacon_web/controllers/error_html_test.exs b/test/beacon_web/controllers/error_html_test.exs new file mode 100644 index 00000000..60b226b7 --- /dev/null +++ b/test/beacon_web/controllers/error_html_test.exs @@ -0,0 +1,13 @@ +defmodule BeaconWeb.ErrorHTMLTest do + use ExUnit.Case, async: true + + alias BeaconWeb.ErrorHTML + + test "invalid status code" do + assert ErrorHTML.render("invalid", %{conn: nil}) == "Internal Server Error" + end + + test "invalid conn" do + assert ErrorHTML.render("404.html", %{conn: nil}) == "Not Found" + end +end diff --git a/test/beacon_web/live/page_live_test.exs b/test/beacon_web/live/page_live_test.exs index 967723b6..c531c002 100644 --- a/test/beacon_web/live/page_live_test.exs +++ b/test/beacon_web/live/page_live_test.exs @@ -140,8 +140,7 @@ defmodule BeaconWeb.Live.PageLiveTest do test "update resource links on layout publish", %{conn: conn, layout: layout} do Beacon.PubSub.subscribe_to_layout(layout.site, layout.id) - {:ok, layout} = - Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}) + {:ok, layout} = Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}) id = layout.id {:ok, _layout} = Content.publish_layout(layout) diff --git a/test/mix/tasks/install_test.exs b/test/mix/tasks/install_test.exs index a69ce876..7495a9a6 100644 --- a/test/mix/tasks/install_test.exs +++ b/test/mix/tasks/install_test.exs @@ -27,6 +27,7 @@ defmodule Mix.Tasks.Beacon.InstallTest do Install.run(["--site", "my_site"]) # Injects beacon repo config into config file + # and sets Endpoint's :render_errors option assert File.read!(@config_path) == """ # This file is responsible for configuring your application @@ -44,10 +45,7 @@ defmodule Mix.Tasks.Beacon.InstallTest do # Configures the endpoint config :my_app, MyAppWeb.Endpoint, url: [host: "localhost"], - render_errors: [ - formats: [html: MyAppWeb.ErrorHTML, json: MyAppWeb.ErrorJSON], - layout: false - ], + render_errors: [formats: [html: BeaconWeb.ErrorHTML]], pubsub_server: MyApp.PubSub, live_view: [signing_salt: "Ozb0CE3q"] diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 6db08edb..8b4fd8cb 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -1,5 +1,6 @@ defmodule Beacon.Fixtures do alias Beacon.Content + alias Beacon.Content.ErrorPage alias Beacon.Content.PageEventHandler alias Beacon.Content.PageVariant alias Beacon.MediaLibrary @@ -185,4 +186,17 @@ defmodule Beacon.Fixtures do |> PageEventHandler.changeset(full_attrs) |> Repo.insert!() end + + def error_page_fixture(attrs \\ %{}) do + layout = get_lazy(attrs, :layout, fn -> layout_fixture() end) + + attrs + |> Enum.into(%{ + site: "my_site", + status: Enum.random(ErrorPage.valid_statuses()), + template: "Uh-oh!", + layout_id: layout.id + }) + |> Content.create_error_page!() + end end