Current section
Files
Jump to
Current section
Files
lib/backpex/item_actions/item_action.ex
defmodule Backpex.ItemAction do
@moduledoc """
Behaviour implemented by all item actions.
"""
import Phoenix.Component
alias Backpex.ItemActions.Delete
alias Backpex.ItemActions.Edit
alias Backpex.ItemActions.Show
alias Phoenix.LiveView.Rendered
alias Phoenix.LiveView.Socket
@doc """
Action icon
"""
@callback icon(assigns :: map(), item :: struct()) :: %Rendered{}
@doc """
A list of fields to be displayed in the item action. See `Backpex.Field`. In addition you have to provide
a `type` for each field in order to support changeset generation.
The following fields are currently not supported:
- `Backpex.Fields.BelongsTo`
- `Backpex.Fields.HasMany`
- `Backpex.Fields.HasManyThrough`
- `Backpex.Fields.Upload`
"""
@callback fields() :: list()
@doc """
The base item / schema to use for the changeset. The result will be passed as the first parameter to `c:changeset/3` each time it is called.
This function is optional and can be used to use changesets with schemas in item actions. If this function is not provided,
a schemaless changeset will be created with the provided types from `c:fields/0`.
"""
@callback base_schema(assigns :: map()) ::
Ecto.Schema.t()
| Ecto.Changeset.t()
| {Ecto.Changeset.data(), Ecto.Changeset.types()}
@doc """
The changeset to be used in the item action. It is used to validate form inputs.
Additional metadata is passed as a keyword list via the metadata parameter.
The list of metadata:
- `:assigns` - the assigns
- `:target` - the name of the `form` target that triggered the changeset call. Defaults to `nil` if the call was not triggered by a form field.
"""
@callback changeset(
change ::
Ecto.Schema.t()
| Ecto.Changeset.t()
| {Ecto.Changeset.data(), Ecto.Changeset.types()},
attrs :: map(),
metadata :: keyword()
) :: Ecto.Changeset.t()
@doc """
Action label (Show label on hover)
"""
@callback label(assigns :: map(), item :: struct() | nil) :: binary()
@doc """
Returns a URL path for the item action. When implemented, the action renders as a `<.link navigate={path}>`
instead of a `<button>`. This enables standard browser link behavior such as Ctrl+click to open in a new tab
and right-click context menus.
"""
@callback link(assigns :: map(), item :: struct()) :: binary()
@doc """
Confirm button label
"""
@callback confirm_label(assigns :: map()) :: binary()
@doc """
cancel button label
"""
@callback cancel_label(assigns :: map()) :: binary()
@doc """
This text is being displayed in the confirm dialog.
There won't be any confirmation when this function is not defined.
"""
@callback confirm(assigns :: map()) :: binary()
@doc """
Performs the action. It takes the socket, the list of affected items, and the casted and validated data (received from [`Ecto.Changeset.apply_action/2`](https://hexdocs.pm/ecto/Ecto.Changeset.html#apply_action/2)).
Exactly one of `c:handle/3` or `c:link/2` must be implemented for each item action.
If `c:link/2` is implemented, `c:handle/3` must not be defined, and vice versa. Link-based actions navigate directly without a server round-trip.
You must return either `{:ok, socket}` or `{:error, changeset}`.
If `{:ok, socket}` is returned, the action is considered successful by Backpex and the action modal is closed. However, you can add an error flash message to the socket to indicate that something has gone wrong.
If `{:error, changeset}` is returned, the changeset is used to update the form to display the errors. Note that Backpex already validates the form for you.
Therefore it is only necessary in rare cases to perform additional validation and return a changeset from `c:handle/3`.
For example, if you are building a duplicate action and can only check for a unique constraint when inserting the duplicate element.
You are only allowed to return `{:error, changeset}` if the action has a form. Otherwise Backpex will throw an ArgumentError.
"""
@callback handle(socket :: Socket.t(), items :: list(map()), params :: map() | struct()) ::
{:ok, Socket.t()} | {:error, Ecto.Changeset.t()}
@optional_callbacks confirm: 1, confirm_label: 1, cancel_label: 1, changeset: 3, fields: 0, link: 2, handle: 3
@doc """
Defines `Backpex.ItemAction` behaviour and provides default implementations.
"""
defmacro __using__(_opts) do
quote do
@behaviour Backpex.ItemAction
require Backpex
@before_compile Backpex.ItemAction
end
end
defmacro __before_compile__(env) do
validate_handle_or_link!(env)
quote generated: true do
@after_compile Backpex.ItemAction
@impl Backpex.ItemAction
def confirm_label(assigns), do: Backpex.__("Apply", assigns.live_resource)
@impl Backpex.ItemAction
def cancel_label(assigns), do: Backpex.__("Cancel", assigns.live_resource)
@impl Backpex.ItemAction
def fields, do: []
@impl Backpex.ItemAction
def base_schema(_assigns) do
types = fields() |> Backpex.Field.changeset_types()
{%{}, types}
end
end
end
def __after_compile__(env, _bytecode) do
# Check if the module has non-empty fields but no changeset/3
module = env.module
changeset_function? = function_exported?(module, :changeset, 3)
confirm_function? = function_exported?(module, :confirm, 1)
fields? = function_exported?(module, :fields, 0) and module.fields() != []
if fields? and (not changeset_function? or not confirm_function?) do
raise CompileError,
file: env.file,
line: env.line,
description: """
ItemAction #{inspect(module)} defines fields but does not implement the changeset/3 and confirm/1 callbacks.
When an ItemAction has fields, it must implement the changeset/3 callback to handle form validation and data processing, and the confirm/1 callback to set the confirmation message.
For example:
@impl Backpex.ItemAction
def confirm(assigns) do
"Are you sure you want to apply this action?"
end
@impl Backpex.ItemAction
def changeset(change, attrs, _metadata) do
change
|> Ecto.Changeset.cast(attrs, [:field1, :field2])
|> Ecto.Changeset.validate_required([:field1])
end
"""
else
# fields/0 is not defined, which means it will use the default empty list
:ok
end
end
defp validate_handle_or_link!(env) do
handle_function? = Module.defines?(env.module, {:handle, 3}, :def)
link_function? = Module.defines?(env.module, {:link, 2}, :def)
cond do
not handle_function? and not link_function? ->
raise CompileError,
file: env.file,
line: env.line,
description: """
ItemAction #{inspect(env.module)} must implement either handle/3 or link/2.
Implement handle/3 for actions that perform server-side operations,
or link/2 for actions that navigate to a URL.
"""
handle_function? and link_function? ->
raise CompileError,
file: env.file,
line: env.line,
description: """
ItemAction #{inspect(env.module)} implements both handle/3 and link/2.
An item action must implement exactly one of these callbacks.
Use handle/3 for actions that perform server-side operations,
or link/2 for actions that navigate to a URL.
"""
true ->
:ok
end
end
@doc """
Checks whether item action has confirmation modal.
"""
def has_confirm_modal?(item_action) do
module = Map.fetch!(item_action, :module)
function_exported?(module, :confirm, 1)
end
@doc """
Checks whether item action has form.
"""
def has_form?(item_action) do
module = Map.fetch!(item_action, :module)
module.fields() != []
end
@doc """
Checks whether item action has a link callback.
"""
def has_link?(item_action) do
module = Map.fetch!(item_action, :module)
Code.ensure_loaded?(module) and function_exported?(module, :link, 2)
end
@doc """
Returns default item actions.
"""
def default_actions do
[
show: %{
module: Show,
only: [:row]
},
edit: %{
module: Edit,
only: [:row, :show]
},
delete: %{
module: Delete,
only: [:row, :index, :show]
}
]
end
@doc """
Handles an item action by executing the action's handle function.
This function filters items based on authorization, executes the action,
and allows customization of post-action behavior via the `after_handle` callback.
"""
def handle_item_action(socket, action, key, items, after_handle) do
live_resource = socket.assigns.live_resource
authorized_items = Enum.filter(items, fn item -> live_resource.can?(socket.assigns, key, item) end)
case action.module.handle(socket, authorized_items, %{}) do
{:ok, socket} ->
after_handle.(socket)
unexpected_return ->
raise ArgumentError, """
Invalid return value from #{inspect(action.module)}.handle/3.
Expected: {:ok, socket}
Got: #{inspect(unexpected_return)}
Item Actions with no form fields must return {:ok, socket}.
"""
end
end
@doc """
Prepares the socket for opening an action confirmation modal.
If the action has a form, it creates a changeset and assigns it along with the base schema.
Otherwise, it assigns an empty changeset.
"""
def assign_action_changeset(socket, action) do
if has_form?(action) do
changeset_function = fn item, changes, metadata -> action.module.changeset(item, changes, metadata) end
base_schema = action.module.base_schema(socket.assigns)
metadata = Backpex.Resource.build_changeset_metadata(socket.assigns)
changeset = changeset_function.(base_schema, %{}, metadata)
socket
|> assign(:action_item, base_schema)
|> assign(:changeset, changeset)
else
assign(socket, :changeset, %{})
end
end
end