Skip to content

Commit

Permalink
Error Pages (#331)
Browse files Browse the repository at this point in the history
* Beacon install integration test

* Test data_source, mixfile, seeds

* Credo

* Fixes

* Bump tailwind version

* Idempotence tests

* Update template

* Resolve compile warnings

* Schema

* Context functions

* Migration

* Update docs

* Inject in mix task

* Test

* Error pages backend

* Minor fixes

* Fix typespec

* Unique index

* Add test for create error

* Use error layout

* Small doc fixes

* Validate error status codes

* Switch error pages to module recompilation

* CI fixes

* Fix test fixtures

* render

* test render/1

* publish default layouts

* Improve test fixture

* Test custom error page

* PubSub

* Still not working

* render error page with root layout

compile a function for each part of the layout tree: root, layout, and page
where root takes the content of page+layout

* fix layout path

* pass @conn to root_layout

* test ErrorHTML

* Check render and layout for my_component

* fix tests and cleanup

* add logger when error page render fails

* use Phoenix.HTML.Engine

* add tests rendering my_component

* remove support for components in error pages

It's possible to load those but requires injecting the LivePage env,
which is risky because a server error may happen due to internals or even
due to the components used in the pages, causing an endless loop.
So for now we won't load components due to the risk they impose but we can
revisit this if needed or if we find a safer approach.

* broadcast error_page_updated event only on update_error_page/2

* fix dialyzer

* fix dialyzer 2

* compile error page content and reload css

---------

Co-authored-by: Leandro Pereira <leandro@leandro.io>
  • Loading branch information
APB9785 and leandrocp authored Oct 17, 2023
1 parent 9a72447 commit f568112
Show file tree
Hide file tree
Showing 21 changed files with 732 additions and 48 deletions.
5 changes: 4 additions & 1 deletion dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
148 changes: 146 additions & 2 deletions lib/beacon/content.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 """
Expand Down
68 changes: 68 additions & 0 deletions lib/beacon/content/error_page.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions lib/beacon/content/layout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit f568112

Please sign in to comment.