Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Beacon.LiveAdmin.Config #306

Merged
merged 3 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,7 @@ defmodule DemoWeb.Router do

scope "/" do
pipe_through :browser

beacon_live_admin("/admin",
additional_pages: [{"/custom", DemoWeb.CustomPage, :show, %{val: 1}}]
)
beacon_live_admin("/admin", additional_pages: [{"/custom", DemoWeb.CustomPage, :show, %{val: 1}}])
end
end

Expand Down
36 changes: 35 additions & 1 deletion lib/beacon/live_admin.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
defmodule Beacon.LiveAdmin do
@moduledoc """
Phoenix LiveView Admin for Beacon
Beacon LiveAdmin is a Phoenix LiveView web application to manage Beacon sites.
"""

use Supervisor
require Logger
alias Beacon.LiveAdmin.Config

@doc """
Starts Beacon LiveAdmin's instances.
"""
def start_link(opts) when is_list(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end

@doc false
@impl true
def init(opts) do
instances = Keyword.get(opts, :instances, [])

if instances == [] do
Logger.warning("Beacon.LiveAdmin will start with no instances configured. See `Beacon.LiveAdmin.start_link/1` for more info.")
end

children =
Enum.map(instances, fn opts ->
opts
|> Config.new()
|> instance_child_spec()
end)

Supervisor.init(children, strategy: :one_for_one)
end

defp instance_child_spec(%Config{} = config) do
Supervisor.child_spec({Beacon.LiveAdmin.InstanceSupervisor, config}, id: config.name)
end
end
4 changes: 2 additions & 2 deletions lib/beacon/live_admin/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ defmodule Beacon.LiveAdmin.Application do
@impl true
def start(_type, _args) do
children = [
Beacon.LiveAdmin.Registry,
{Phoenix.PubSub, name: Beacon.LiveAdmin.PubSub},
Beacon.LiveAdmin.Cluster
]

opts = [strategy: :one_for_one, name: Beacon.LiveAdmin.Supervisor]
Supervisor.start_link(children, opts)
Supervisor.start_link(children, strategy: :one_for_one, name: Beacon.LiveAdmin.Supervisor)
end
end
86 changes: 86 additions & 0 deletions lib/beacon/live_admin/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule Beacon.LiveAdmin.Config do
@moduledoc """
Configuration for Admin instances.

See `new/1` for available options and examples.
"""

@doc false
use GenServer

alias Beacon.LiveAdmin.ConfigError

@doc false
def name(name) do
Beacon.Registry.via({name, __MODULE__})
end

@doc false
def start_link(config) do
GenServer.start_link(__MODULE__, config, name: Beacon.Registry.via({config.name, __MODULE__}, config))
end

@doc false
def init(config) do
{:ok, config}
end

@typedoc """
Name to identify an Admin instance.
"""
@type name :: atom()

@type t :: %__MODULE__{
name: name()
}

defstruct name: nil

@type option ::
{:name, name()}

@doc """
Build a new `%Beacon.LiveAdmin.Config{}` to hold the entire configuration for each Admin instance.

## Options

* `:site` - `t:name/0` (required). Name of the Admin instance, must be unique. Defaults to `:admin`.

## Example

iex> Beacon.LiveAdmin.Config.new(
name: :my_admin
)
%Beacon.LiveAdmin.Config{
name: :my_admin
}

"""
@spec new([option]) :: t()
def new(opts) do
# TODO: validate opts, maybe use nimble_options

opts =
opts
|> Keyword.put_new(:name, :admin)

struct!(__MODULE__, opts)
end

@doc """
Returns the `Beacon.LiveAdmin.Config` for instance `name`.
"""
@spec fetch!(name()) :: t()
def fetch!(name) when is_atom(name) do
case Beacon.Registry.lookup({name, __MODULE__}) do
{_pid, config} ->
config

_ ->
raise ConfigError, """
Admin instance #{inspect(name)} not found. Make sure it's configured and started,
see `Beacon.LiveAdmin.start_link/1` for more info.
"""
end
end
end
11 changes: 11 additions & 0 deletions lib/beacon/live_admin/errors.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
defmodule Beacon.LiveAdmin.ConfigError do
@moduledoc """
Raised when some option in `Beacon.LiveAdmin.Config` is invalid.

If you are seeing this error, check `application.ex` where your admin instance config is defined.
Description and examples of each allowed configuration option can be found in `Beacon.LiveAdmin.Config.new/1`
"""

defexception message: "error in Beacon.LiveAdmin.Config"
end

defmodule Beacon.LiveAdmin.ClusterError do
@moduledoc """
Raised when there is an error in calling a remove Beacon function through the cluster.
Expand Down
18 changes: 18 additions & 0 deletions lib/beacon/live_admin/instance_supervisor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule Beacon.LiveAdmin.InstanceSupervisor do
@moduledoc false

use Supervisor

def start_link(config) do
Supervisor.start_link(__MODULE__, config, name: Beacon.LiveAdmin.Registry.via({:instance, config.name}))
end

@impl true
def init(config) do
children = [
{Beacon.LiveAdmin.Config, config}
]

Supervisor.init(children, strategy: :one_for_one)
end
end
17 changes: 17 additions & 0 deletions lib/beacon/live_admin/registry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule Beacon.LiveAdmin.Registry do
@moduledoc false

def child_spec(_arg) do
Registry.child_spec(keys: :unique, name: __MODULE__)
end

def via(key), do: {:via, Registry, {__MODULE__, key}}

def via(key, value), do: {:via, Registry, {__MODULE__, key, value}}

def lookup(key) do
__MODULE__
|> Registry.lookup(key)
|> List.first()
end
end
46 changes: 39 additions & 7 deletions lib/beacon/live_admin/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ defmodule Beacon.LiveAdmin.Router do
Routing for Beacon LiveAdmin.
"""

require Logger

@type conn_or_socket :: Phoenix.LiveView.Socket.t() | Plug.Conn.t()

defmacro __using__(_opts) do
Expand Down Expand Up @@ -61,8 +63,10 @@ defmodule Beacon.LiveAdmin.Router do

## Options

* `:on_mount` (optional) , an optional list of `on_mount` hooks passed to `live_session`.
This will allow for authenticated routes, among other uses.
* `:name` (required) `atom()` - register your instance with a unique name.
Note that the name has to match the one used in your instance configuration.
* `:on_mount` (optional) - an optional list of `on_mount` hooks passed to `live_session`.
This will allow for authenticated routes, among other uses.

"""
defmacro beacon_live_admin(prefix, opts \\ []) do
Expand All @@ -88,8 +92,7 @@ defmodule Beacon.LiveAdmin.Router do
{additional_pages, opts} = Keyword.pop(opts, :additional_pages, [])
pages = Beacon.LiveAdmin.Router.__pages__(additional_pages)

{session_name, session_opts} =
Beacon.LiveAdmin.Router.__session_options__(prefix, pages, opts)
{_instance_name, session_name, session_opts} = Beacon.LiveAdmin.Router.__options__(pages, opts)

import Phoenix.Router, only: [get: 4]
import Phoenix.LiveView.Router, only: [live: 4, live_session: 3]
Expand Down Expand Up @@ -196,8 +199,36 @@ defmodule Beacon.LiveAdmin.Router do
end

@doc false
def __session_options__(prefix, pages, opts) do
# TODO validate options
# TODO validate options
def __options__(pages, opts) do
instance_name =
Keyword.get_lazy(opts, :name, fn ->
Logger.warning("""
missing required option :name in beacon_live_admin/2

It will default to :admin but it's recommended to provide a unique name for your instance.

Example:

beacon_live_admin "/admin", name: :admin

""")

:admin
end)

instance_name =
cond do
String.starts_with?(Atom.to_string(instance_name), ["beacon", "__beacon"]) ->
raise ArgumentError, ":name can not start with beacon or __beacon, got: #{instance_name}"

instance_name && is_atom(instance_name) ->
instance_name

:invalid ->
raise ArgumentError, ":name must be an atom, got: #{inspect(instance_name)}"
end

if Keyword.has_key?(opts, :root_layout) do
raise ArgumentError, """
you cannot assign a different root_layout.
Expand All @@ -221,7 +252,8 @@ defmodule Beacon.LiveAdmin.Router do
]

{
opts[:live_session_name] || String.to_atom("beacon_live_admin_#{prefix}"),
instance_name,
opts[:live_session_name] || String.to_atom("beacon_live_admin_#{instance_name}"),
[
root_layout: {Beacon.LiveAdmin.Layouts, :admin},
session: {__MODULE__, :__session__, session_args},
Expand Down
17 changes: 8 additions & 9 deletions test/beacon/live_admin/router_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -59,42 +59,41 @@ defmodule Beacon.LiveAdmin.RouterTest do
end

describe "session options" do
test "session name based on prefix" do
assert {:beacon_live_admin_prefix, _} = Router.__session_options__("prefix", [], [])
test "session name based on instance name" do
assert {_, :beacon_live_admin_test, _} = Router.__options__([], name: :test)
end

test "allow adding custom mount hooks" do
assert {:beacon_live_admin_prefix,
assert {:admin, :beacon_live_admin_admin,
[
root_layout: {Beacon.LiveAdmin.Layouts, :admin},
session: {Beacon.LiveAdmin.Router, :__session__, [[]]},
on_mount: [SomeHook]
]} = Router.__session_options__("prefix", [], on_mount: [SomeHook])
]} = Router.__options__([], on_mount: [SomeHook])
end

test "preserve Hooks.AssignAgent position if defined by user" do
assert {:beacon_live_admin_admin,
assert {:admin, :beacon_live_admin_admin,
[
root_layout: {Beacon.LiveAdmin.Layouts, :admin},
session: {Beacon.LiveAdmin.Router, :__session__, [[]]},
on_mount: [AssignUser, Beacon.LiveAdmin.Hooks.AssignAgent, SomeHook]
]} =
Router.__session_options__(
"admin",
Router.__options__(
[],
on_mount: [AssignUser, Beacon.LiveAdmin.Hooks.AssignAgent, SomeHook]
)
end

test "does not assign root_layout" do
assert_raise ArgumentError, fn ->
Router.__session_options__("admin", [], root_layout: {MyApp.Layouts, :other})
Router.__options__([], root_layout: {MyApp.Layouts, :other})
end
end

test "does not assign layout" do
assert_raise ArgumentError, fn ->
Router.__session_options__("admin", [], layout: {MyApp.Layouts, :other})
Router.__options__([], layout: {MyApp.Layouts, :other})
end
end
end
Expand Down
Loading