From 42826d9a1bc144d43dd819eb9f3db385a1e0c37e Mon Sep 17 00:00:00 2001 From: Leandro Pereira Date: Wed, 27 Nov 2024 10:35:37 -0500 Subject: [PATCH 1/3] Introduce `Beacon.LiveAdmin.Config` Just the skeleton for now but it will hold some config like `:sites` (filter managed sites) --- dev.exs | 5 +- lib/beacon/live_admin.ex | 36 +++++++- lib/beacon/live_admin/application.ex | 4 +- lib/beacon/live_admin/config.ex | 86 ++++++++++++++++++++ lib/beacon/live_admin/errors.ex | 11 +++ lib/beacon/live_admin/instance_supervisor.ex | 18 ++++ lib/beacon/live_admin/registry.ex | 17 ++++ 7 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 lib/beacon/live_admin/config.ex create mode 100644 lib/beacon/live_admin/instance_supervisor.ex create mode 100644 lib/beacon/live_admin/registry.ex diff --git a/dev.exs b/dev.exs index fc8fce85..2b622ed0 100644 --- a/dev.exs +++ b/dev.exs @@ -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 diff --git a/lib/beacon/live_admin.ex b/lib/beacon/live_admin.ex index 7b5e6523..3db29b78 100644 --- a/lib/beacon/live_admin.ex +++ b/lib/beacon/live_admin.ex @@ -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 main supervisor and a supervisor for each instance. + """ + 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 diff --git a/lib/beacon/live_admin/application.ex b/lib/beacon/live_admin/application.ex index a89e4711..e117bf0b 100644 --- a/lib/beacon/live_admin/application.ex +++ b/lib/beacon/live_admin/application.ex @@ -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 diff --git a/lib/beacon/live_admin/config.ex b/lib/beacon/live_admin/config.ex new file mode 100644 index 00000000..7d56163a --- /dev/null +++ b/lib/beacon/live_admin/config.ex @@ -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 diff --git a/lib/beacon/live_admin/errors.ex b/lib/beacon/live_admin/errors.ex index 7cdc41d9..8a6fc6ff 100644 --- a/lib/beacon/live_admin/errors.ex +++ b/lib/beacon/live_admin/errors.ex @@ -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. diff --git a/lib/beacon/live_admin/instance_supervisor.ex b/lib/beacon/live_admin/instance_supervisor.ex new file mode 100644 index 00000000..ce0f445d --- /dev/null +++ b/lib/beacon/live_admin/instance_supervisor.ex @@ -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 diff --git a/lib/beacon/live_admin/registry.ex b/lib/beacon/live_admin/registry.ex new file mode 100644 index 00000000..c20eda6c --- /dev/null +++ b/lib/beacon/live_admin/registry.ex @@ -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 From 45b7ff1b32f165db9f540a22f5dc464cb9449565 Mon Sep 17 00:00:00 2001 From: Leandro Pereira Date: Wed, 27 Nov 2024 10:49:14 -0500 Subject: [PATCH 2/3] add :name opt in beacon_live_admin --- lib/beacon/live_admin.ex | 2 +- lib/beacon/live_admin/router.ex | 46 ++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/beacon/live_admin.ex b/lib/beacon/live_admin.ex index 3db29b78..19a76c7b 100644 --- a/lib/beacon/live_admin.ex +++ b/lib/beacon/live_admin.ex @@ -8,7 +8,7 @@ defmodule Beacon.LiveAdmin do alias Beacon.LiveAdmin.Config @doc """ - Starts Beacon LiveAdmin's main supervisor and a supervisor for each instance. + Starts Beacon LiveAdmin's instances. """ def start_link(opts) when is_list(opts) do Supervisor.start_link(__MODULE__, opts, name: __MODULE__) diff --git a/lib/beacon/live_admin/router.ex b/lib/beacon/live_admin/router.ex index 3001bc1e..fb36ea1e 100644 --- a/lib/beacon/live_admin/router.ex +++ b/lib/beacon/live_admin/router.ex @@ -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 @@ -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 @@ -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] @@ -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. @@ -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}, From d973de96fd218be13abdfc53b803e29b8900566d Mon Sep 17 00:00:00 2001 From: Leandro Pereira Date: Wed, 27 Nov 2024 10:54:46 -0500 Subject: [PATCH 3/3] fix tests --- test/beacon/live_admin/router_test.exs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/beacon/live_admin/router_test.exs b/test/beacon/live_admin/router_test.exs index 0c0506bd..336a0337 100644 --- a/test/beacon/live_admin/router_test.exs +++ b/test/beacon/live_admin/router_test.exs @@ -59,28 +59,27 @@ 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] ) @@ -88,13 +87,13 @@ defmodule Beacon.LiveAdmin.RouterTest do 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