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