diff --git a/guides/actions/item-actions.md b/guides/actions/item-actions.md index 92db78b4c..47e56e585 100644 --- a/guides/actions/item-actions.md +++ b/guides/actions/item-actions.md @@ -38,6 +38,8 @@ In the above example, we only return the `show` item action. This way we replace An item action is a module that uses the `Backpex.ItemAction` module. To get started, you can use the `BackpexWeb` module and provide the `:item_action` option. This will import the necessary functions and macros to define an item action. +If the item action needs your application's Gettext backend, components, or verified routes, you can additionally `use MyAppWeb, :html`. Both `use` orders are supported. + In the following example, we define an item action to navigate to the show view of a user. ```elixir diff --git a/guides/fields/custom-fields.md b/guides/fields/custom-fields.md index 1c39f614a..db7219473 100644 --- a/guides/fields/custom-fields.md +++ b/guides/fields/custom-fields.md @@ -53,6 +53,18 @@ The `render_form/1` function returns markup that is used to render a form on `ed See `Backpex.Field` for more information on the available callback functions. For example, you can implement `render_index_form/1` to make the field editable in the index view. +## Using your application's web helpers + +Custom fields are LiveComponents. If a field needs helpers from your application, such as Gettext, core components, or verified routes, configure your application's LiveComponent entrypoint: + +```elixir +use Backpex.Field, + config_schema: @config_schema, + live_component: {MyAppWeb, :live_component} +``` + +The standard Phoenix-generated `MyAppWeb, :live_component` entrypoint includes the application's HTML helpers while setting up `Phoenix.LiveComponent` exactly once. Do not additionally `use MyAppWeb, :html` in the same field module. + ## Add field option validation With Backpex v0.9 we are validating field options. This ensures that only field options that are actually used by the field can be defined in the field options map. So if your custom field requires certain field options, make sure you define them. diff --git a/guides/filter/custom-filter.md b/guides/filter/custom-filter.md index 4110a82c9..292afe5ca 100644 --- a/guides/filter/custom-filter.md +++ b/guides/filter/custom-filter.md @@ -6,6 +6,8 @@ Backpex ships with a set of default filters that can be used to filter the data. You can create a custom filter by using the `filter` macro from the `BackpexWeb` module. It automatically implements the `Backpex.Filter` behavior and defines some aliases and imports. +If the filter needs your application's Gettext backend, components, or verified routes, you can additionally `use MyAppWeb, :html`. This also works with the built-in filter macros such as `use Backpex.Filters.Select`, and both `use` orders are supported. + ### Required Callbacks When creating a custom filter, you need to implement the following callbacks: diff --git a/guides/upgrading/v0.19.md b/guides/upgrading/v0.19.md index 177c047bd..09c6cab40 100644 --- a/guides/upgrading/v0.19.md +++ b/guides/upgrading/v0.19.md @@ -63,3 +63,47 @@ defmodule MyAppWeb.PostLive do end end ``` + +## Backpex extension modules now import `Phoenix.Component` + +The `BackpexWeb` entrypoints for item actions, filters, and metrics now import +`Phoenix.Component` instead of using it. This avoids registering the +declarative `@before_compile` hook twice when an extension also uses its application's +HTML entrypoint. + +You can now use application-level Gettext, components, and verified routes directly in +these extensions, in either order: + +```elixir +defmodule MyAppWeb.ItemActions.Archive do + use BackpexWeb, :item_action + use MyAppWeb, :html + + # ... +end +``` + +This also applies to custom filters using `BackpexWeb, :filter` or one of +`Backpex.Filters.Select`, `Backpex.Filters.MultiSelect`, `Backpex.Filters.Boolean`, and +`Backpex.Filters.Range`, as well as metrics using `BackpexWeb, :metric`. + +No change is required for extensions that only use `~H` and the helpers provided by +Backpex. If an item action, filter, or metric declares its own function components with +`attr` or `slot`, it must now additionally `use MyAppWeb, :html` or +`use Phoenix.Component`. + +## Custom fields can use the host LiveComponent entrypoint + +`Backpex.Field` still needs the complete `Phoenix.LiveComponent` setup. To make your +application's Gettext backend, components, and verified routes available without using +`Phoenix.Component` twice, configure the host LiveComponent entrypoint: + +```elixir +use Backpex.Field, + config_schema: @config_schema, + live_component: {MyAppWeb, :live_component} +``` + +The configured entrypoint must set up a `Phoenix.LiveComponent`, as the standard +Phoenix-generated `MyAppWeb, :live_component` entrypoint does. It replaces Backpex's +default LiveComponent setup, so do not additionally `use MyAppWeb, :html` in the field. diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index b73072605..8fa414541 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -226,15 +226,35 @@ defmodule Backpex.Field do @doc """ Defines `Backpex.Field` behaviour and provides default implementations. + + A custom field can use its application's LiveComponent entrypoint to make + application helpers such as Gettext and verified routes available: + + use Backpex.Field, + config_schema: @config_schema, + live_component: {MyAppWeb, :live_component} + + The configured entrypoint must set up a `Phoenix.LiveComponent`, as the standard + Phoenix-generated `MyAppWeb, :live_component` entrypoint does. """ defmacro __using__(opts) do - quote bind_quoted: [opts: opts] do + opts = Macro.expand(opts, __CALLER__) + + if !Keyword.keyword?(opts) do + raise ArgumentError, "expected Backpex.Field options to be a keyword list, got: #{Macro.to_string(opts)}" + end + + {live_component, opts} = Keyword.pop(opts, :live_component) + live_component = live_component(live_component, __CALLER__) + + quote bind_quoted: [opts: opts], unquote: true do @config_schema opts[:config_schema] || [] @before_compile Backpex.Field @behaviour Backpex.Field - use BackpexWeb, :field + unquote(live_component) + use BackpexWeb, :field_helpers @doc """ Returns the schema of configurable options for this field. @@ -262,6 +282,30 @@ defmodule Backpex.Field do end end + defp live_component(nil, _caller) do + quote do + use Phoenix.LiveComponent + end + end + + defp live_component({module, entrypoint}, caller) when is_atom(entrypoint) do + module = Macro.expand(module, caller) + + if !is_atom(module) do + raise ArgumentError, + "expected :live_component to contain a module, got: #{Macro.to_string(module)}" + end + + quote do + use unquote(module), unquote(entrypoint) + end + end + + defp live_component(live_component, _caller) do + raise ArgumentError, + "expected :live_component to be a {module, entrypoint} tuple, got: #{Macro.to_string(live_component)}" + end + defmacro __before_compile__(_env) do quote generated: true do import Ecto.Query diff --git a/lib/backpex/filters/boolean.ex b/lib/backpex/filters/boolean.ex index 5aca3c24d..13fa4eebb 100644 --- a/lib/backpex/filters/boolean.ex +++ b/lib/backpex/filters/boolean.ex @@ -40,6 +40,7 @@ defmodule Backpex.Filters.Boolean do > In addition it will add a `render` and `render_form` function in order to display the corresponding filter. > It will also implement the `Backpex.Filter.query` function to define a boolean query. """ + use Phoenix.Component use BackpexWeb, :filter @doc """ diff --git a/lib/backpex/filters/multi_select.ex b/lib/backpex/filters/multi_select.ex index 5fe334535..194a6a7bc 100644 --- a/lib/backpex/filters/multi_select.ex +++ b/lib/backpex/filters/multi_select.ex @@ -24,6 +24,7 @@ defmodule Backpex.Filters.MultiSelect do > > When you `use Backpex.Filters.MultiSelect`, the `Backpex.Filters.MultiSelect` module will set `@behavior Backpex.Filters.Select`. In addition it will add a `render` and `render_form` function in order to display the corresponding filter. """ + use Phoenix.Component use BackpexWeb, :filter import Backpex.HTML.CoreComponents diff --git a/lib/backpex/filters/range.ex b/lib/backpex/filters/range.ex index d622ecd44..969377aed 100644 --- a/lib/backpex/filters/range.ex +++ b/lib/backpex/filters/range.ex @@ -24,6 +24,7 @@ defmodule Backpex.Filters.Range do > In addition it will add a `render` and `render_form` function in order to display the corresponding filter. > It will also implement the `Backpex.Filter.query` function to define a range query. """ + use Phoenix.Component use BackpexWeb, :filter require Backpex diff --git a/lib/backpex/filters/select.ex b/lib/backpex/filters/select.ex index b05afe079..a3eee2350 100644 --- a/lib/backpex/filters/select.ex +++ b/lib/backpex/filters/select.ex @@ -30,6 +30,7 @@ defmodule Backpex.Filters.Select do > When you `use Backpex.Filters.Select`, the `Backpex.Filters.Select` module will set `@behavior Backpex.Filters.Select`. > In addition it will add a `render` and `render_form` function in order to display the corresponding filter. """ + use Phoenix.Component use BackpexWeb, :filter @doc """ diff --git a/lib/backpex/metrics/value.ex b/lib/backpex/metrics/value.ex index 0cc6db9b4..875a8f559 100644 --- a/lib/backpex/metrics/value.ex +++ b/lib/backpex/metrics/value.ex @@ -32,6 +32,7 @@ defmodule Backpex.Metrics.Value do end """ + use Phoenix.Component use BackpexWeb, :metric attr :metric, :any, required: true, doc: "the metric to be rendered" diff --git a/lib/backpex_web.ex b/lib/backpex_web.ex index 6464eaf3b..03a54faed 100644 --- a/lib/backpex_web.ex +++ b/lib/backpex_web.ex @@ -19,6 +19,24 @@ defmodule BackpexWeb do end end + @doc """ + Includes the functions and helpers needed to render HEEx without setting up a + `Phoenix.Component` module. + + This entrypoint is intended for Backpex extension modules that may additionally + `use MyAppWeb, :html`. Importing `Phoenix.Component` provides the `~H` sigil and + component helpers without registering another declarative `@before_compile` hook. + """ + def heex do + quote do + import Phoenix.Component + + require Phoenix.Component.Declarative + + unquote(html_helpers()) + end + end + @doc """ Includes the functions and helpers available inside a `Backpex.LiveResource`. @@ -40,13 +58,7 @@ defmodule BackpexWeb do # the module additionally brings in the component-aware `def` (via `use MyAppWeb, :html`), # regardless of the order of the `use` statements. def live_resource do - quote do - import Phoenix.Component - - require Phoenix.Component.Declarative - - unquote(html_helpers()) - end + heex() end @doc """ @@ -56,6 +68,13 @@ defmodule BackpexWeb do quote do use Phoenix.LiveComponent + unquote(field_helpers()) + end + end + + @doc false + def field_helpers do + quote do alias Backpex.HTML alias Backpex.HTML.Form, as: BackpexForm alias Backpex.HTML.Layout @@ -71,25 +90,22 @@ defmodule BackpexWeb do """ def item_action do quote do - use Phoenix.Component use Backpex.ItemAction import Phoenix.LiveView alias Backpex.Router - unquote(html_helpers()) + unquote(heex()) end end def filter do quote do - use Phoenix.Component - import Backpex.HTML.Form, only: [error: 1] import Ecto.Query, warn: false - unquote(html_helpers()) + unquote(heex()) end end @@ -97,9 +113,9 @@ defmodule BackpexWeb do quote do @behaviour Backpex.Metric - use Phoenix.Component - import Ecto.Query + + unquote(heex()) end end diff --git a/test/backpex/web_helpers_test.exs b/test/backpex/web_helpers_test.exs new file mode 100644 index 000000000..fc231553f --- /dev/null +++ b/test/backpex/web_helpers_test.exs @@ -0,0 +1,39 @@ +defmodule Backpex.WebHelpersTest do + use ExUnit.Case, async: false + + @fixture Path.expand("../fixtures/web_helpers/extension_modules.fixture", __DIR__) + + @fixture_modules [ + Backpex.WebHelpersTest.Helpers, + Backpex.WebHelpersTest.Web, + Backpex.WebHelpersTest.ItemActionBackpexFirst, + Backpex.WebHelpersTest.ItemActionWebFirst, + Backpex.WebHelpersTest.FilterBackpexFirst, + Backpex.WebHelpersTest.FilterWebFirst, + Backpex.WebHelpersTest.SelectFilterBackpexFirst, + Backpex.WebHelpersTest.SelectFilterWebFirst, + Backpex.WebHelpersTest.MetricBackpexFirst, + Backpex.WebHelpersTest.MetricWebFirst, + Backpex.WebHelpersTest.Field + ] + + test "Backpex extensions compile with host web helpers without warnings" do + purge_fixture_modules() + on_exit(&purge_fixture_modules/0) + + assert {:ok, modules, %{compile_warnings: [], runtime_warnings: []}} = + Kernel.ParallelCompiler.compile([@fixture], + max_concurrency: 1, + return_diagnostics: true + ) + + assert Enum.sort(modules) == Enum.sort(@fixture_modules) + end + + defp purge_fixture_modules do + Enum.each(@fixture_modules, fn module -> + :code.purge(module) + :code.delete(module) + end) + end +end diff --git a/test/fixtures/web_helpers/extension_modules.fixture b/test/fixtures/web_helpers/extension_modules.fixture new file mode 100644 index 000000000..59ae2fc72 --- /dev/null +++ b/test/fixtures/web_helpers/extension_modules.fixture @@ -0,0 +1,167 @@ +# This file is compiled explicitly by Backpex.WebHelpersTest. +defmodule Backpex.WebHelpersTest.Helpers do + @moduledoc false + + def host_helper, do: "host helper" +end + +defmodule Backpex.WebHelpersTest.Web do + @moduledoc false + + def html do + quote do + use Phoenix.Component + + import Backpex.WebHelpersTest.Helpers + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + import Backpex.WebHelpersTest.Helpers + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end + +defmodule Backpex.WebHelpersTest.ItemActionBackpexFirst do + @moduledoc false + + use BackpexWeb, :item_action + use Backpex.WebHelpersTest.Web, :html + + @impl Backpex.ItemAction + def icon(assigns, _item), do: ~H"{host_helper()}" + + @impl Backpex.ItemAction + def label(_assigns, _item), do: host_helper() + + @impl Backpex.ItemAction + def link(_assigns, _item), do: "/items" +end + +defmodule Backpex.WebHelpersTest.ItemActionWebFirst do + @moduledoc false + + use Backpex.WebHelpersTest.Web, :html + use BackpexWeb, :item_action + + @impl Backpex.ItemAction + def icon(assigns, _item), do: ~H"{host_helper()}" + + @impl Backpex.ItemAction + def label(_assigns, _item), do: host_helper() + + @impl Backpex.ItemAction + def link(_assigns, _item), do: "/items" +end + +defmodule Backpex.WebHelpersTest.FilterBackpexFirst do + @moduledoc false + + use BackpexWeb, :filter + use Backpex.Filter + use Backpex.WebHelpersTest.Web, :html + + @impl Backpex.Filter + def render(assigns), do: ~H"{host_helper()}" + + @impl Backpex.Filter + def render_form(assigns), do: ~H"{host_helper()}" +end + +defmodule Backpex.WebHelpersTest.FilterWebFirst do + @moduledoc false + + use Backpex.WebHelpersTest.Web, :html + use BackpexWeb, :filter + use Backpex.Filter + + @impl Backpex.Filter + def render(assigns), do: ~H"{host_helper()}" + + @impl Backpex.Filter + def render_form(assigns), do: ~H"{host_helper()}" +end + +defmodule Backpex.WebHelpersTest.SelectFilterBackpexFirst do + @moduledoc false + + use Backpex.Filters.Select + use Backpex.WebHelpersTest.Web, :html + + alias Backpex.Filters.Select + + @impl Select + def prompt, do: host_helper() + + @impl Select + def options(_assigns), do: [{host_helper(), "value"}] +end + +defmodule Backpex.WebHelpersTest.SelectFilterWebFirst do + @moduledoc false + + use Backpex.WebHelpersTest.Web, :html + use Backpex.Filters.Select + + alias Backpex.Filters.Select + + @impl Select + def prompt, do: host_helper() + + @impl Select + def options(_assigns), do: [{host_helper(), "value"}] +end + +defmodule Backpex.WebHelpersTest.MetricBackpexFirst do + @moduledoc false + + use BackpexWeb, :metric + use Backpex.WebHelpersTest.Web, :html + + @impl Backpex.Metric + def render(assigns), do: ~H"{host_helper()}" + + @impl Backpex.Metric + def query(query, _select, _repo), do: query + + @impl Backpex.Metric + def format(data, _format), do: data +end + +defmodule Backpex.WebHelpersTest.MetricWebFirst do + @moduledoc false + + use Backpex.WebHelpersTest.Web, :html + use BackpexWeb, :metric + + @impl Backpex.Metric + def render(assigns), do: ~H"{host_helper()}" + + @impl Backpex.Metric + def query(query, _select, _repo), do: query + + @impl Backpex.Metric + def format(data, _format), do: data +end + +defmodule Backpex.WebHelpersTest.Field do + @moduledoc false + + use Backpex.Field, + live_component: {Backpex.WebHelpersTest.Web, :live_component} + + attr :value, :any, default: nil + + @impl Backpex.Field + def render_value(assigns), do: ~H"{@value || host_helper()}" + + @impl Backpex.Field + def render_form(assigns), do: ~H"{host_helper()}" +end