defmodule Backpex.HTML.Resource do
@moduledoc """
Contains all Backpex resource components.
"""
use BackpexWeb, :html
import Phoenix.LiveView.TagEngine
import Backpex.HTML.Form
import Backpex.HTML.Layout
alias Backpex.LiveResource
alias Backpex.ResourceAction
alias Backpex.Router
embed_templates("resource/*")
@doc """
Renders a resource table.
"""
@doc type: :component
attr(:socket, :any, required: true)
attr(:live_resource, :any, required: true, doc: "module of the live resource")
attr(:params, :string, required: true, doc: "query parameters")
attr(:query_options, :map, default: %{}, doc: "query options")
attr(:fields, :list, required: true, doc: "list of fields to be displayed in the table on index view")
attr(:orderable_fields, :list, default: [], doc: "list of orderable fields")
attr(:searchable_fields, :list, default: [], doc: "list of searchable fields")
attr(:items, :list, default: [], doc: "items that will be displayed in the table")
attr(:active_fields, :list, required: true, doc: "list of active fields")
attr(:selected_items, :list, required: true, doc: "list of selected items")
def resource_index_table(assigns)
@doc """
Renders a link to change the order direction for a given column.
"""
@doc type: :component
attr(:socket, :map, required: true)
attr(:live_resource, :any, required: true, doc: "module of the live resource")
attr(:params, :string, required: true, doc: "query parameters")
attr(:query_options, :map, required: true, doc: "query options")
attr(:label, :string, required: true, doc: "label to be displayed on the link")
attr(:name, :atom, required: true, doc: "name of the column the link should change order for")
def order_link(assigns) do
order_direction =
if assigns.name == assigns.query_options.order_by do
toggle_order_direction(assigns.query_options.order_direction)
else
:asc
end
assigns =
assigns
|> assign(:next_order_direction, order_direction)
~H"""
<.link
class="flex items-center space-x-1"
patch={
Router.get_path(
@socket,
@live_resource,
@params,
:index,
Map.merge(@query_options, %{order_direction: @next_order_direction, order_by: @name})
)
}
replace
>
<%= @label %>
<%= if @name == @query_options.order_by do %>
<%= order_icon(assigns, @query_options.order_direction) %>
<% end %>
"""
end
defp order_icon(assigns, :asc) do
~H"""
"""
end
defp order_icon(assigns, :desc) do
~H"""
"""
end
@doc """
Renders the field of the given resource.
"""
@doc type: :component
attr(:name, :string, required: true, doc: "name / key of the item field")
attr(:item, :map,
required: true,
doc: "the item which provides the value to be rendered"
)
attr(:fields, :list,
required: true,
doc: "list of all fields provided by the resource configuration"
)
def resource_field(assigns) do
%{name: name, item: item, fields: fields, live_resource: live_resource} = assigns
{_name, field_options} = field = Enum.find(fields, fn {field_name, _field_options} -> field_name == name end)
readonly =
not LiveResource.can?(assigns, :edit, item, live_resource) or
Backpex.Field.readonly?(field_options, assigns)
assigns =
assigns
|> assign(:field, field)
|> assign(:field_options, field_options)
|> assign(:value, Map.get(item, name))
|> assign(:type, :index)
|> assign(:readonly, readonly)
~H"""
<.live_component
id={"resource_#{@name}_#{@item.id}"}
module={@field_options.module}
type={@type}
{Map.drop(assigns, [:socket, :flash, :myself, :uploads])}
/>
"""
end
@doc """
Renders a resource form field.
"""
@doc type: :component
attr(:name, :string, required: true, doc: "name / key of the item field")
attr(:form, :map, required: true, doc: "form that will be used by the form field")
attr(:repo, :any, required: false, doc: "ecto repo")
attr(:uploads, :map, required: false, default: %{}, doc: "map that contains upload information")
attr(:fields, :list,
required: true,
doc: "list of all fields provided by the resource configuration"
)
def resource_form_field(assigns) do
%{name: name, fields: fields} = assigns
{_name, field_options} = field = Enum.find(fields, fn {field_name, _field_options} -> field_name == name end)
assigns =
assigns
|> assign(:field, field)
|> assign(:field_options, field_options)
|> assign(:type, :form)
~H"""
<.live_component
id={"resource_#{@name}"}
module={@field_options.module}
field_uploads={get_in(assigns, [:uploads, @name])}
type={@type}
{Map.drop(assigns, [:socket, :flash, :myself, :uploads])}
/>
"""
end
@doc """
Renders form with a search field. Emits the `simple-search-input` event on change.
"""
@doc type: :component
attr(:searchable_fields, :list,
default: [],
doc: "The fields that can be searched. Here only used to hide the component when empty."
)
attr(:full_text_search, :string, default: nil, doc: "full text search column name")
attr(:value, :string, required: true, doc: "value binding for the search input")
attr(:placeholder, :string, required: true, doc: "placeholder for the search input")
def index_search_form(assigns) do
form = to_form(%{"value" => assigns.value}, as: :index_search)
search_enabled = not is_nil(assigns.full_text_search) or assigns.searchable_fields != []
assigns =
assigns
|> assign(:search_enabled, search_enabled)
|> assign(:form, form)
~H"""
<.form :if={@search_enabled} for={@form} phx-change="index-search" phx-submit="index-search">
"""
end
@doc """
Renders the index filters if the `filter/0` callback is defined in the resource.
"""
@doc type: :component
attr(:live_resource, :any, required: true, doc: "module of the live resource")
attr(:filter_options, :map, required: true, doc: "filter options")
attr(:filters, :list, required: true, doc: "list of active filters")
def index_filter(assigns) do
computed = [
filter_count: Enum.count(assigns.filter_options),
filter_icon_class:
if(assigns.filter_options != %{},
do: "text-primary group-hover:text-white",
else: "text-gray-400 group-hover:text-white"
)
]
assigns = assign(assigns, computed)
~H"""
"""
end
defp filter_presets(%{presets: nil} = assigns), do: ~H""
defp filter_presets(assigns) do
~H"""
<%= preset.label %>
"""
end
defp maybe_clear_button(%{value: nil} = assigns), do: ~H""
defp maybe_clear_button(assigns) do
~H"""
"""
end
attr(:socket, :any, required: true)
attr(:active_fields, :list, required: true, doc: "list of active fields")
attr(:live_resource, :atom, required: true, doc: "the live resource")
attr(:current_url, :string, required: true, doc: "the current url")
attr(:class, :string, default: "", doc: "additional class to be added to the component")
attr(:x_style, :string, default: "", doc: "alpine-bound inline styles for the root div")
def toggle_columns(assigns) do
form =
to_form(%{"_resource" => assigns.live_resource, "_cookie_redirect_url" => assigns.current_url},
as: :toggle_columns
)
assigns = assign(assigns, :form, form)
~H"""
"""
end
attr(:form, :any, required: true, doc: "the form")
attr(:active_fields, :list, required: true, doc: "list of active fields to be displayed")
def toggle_columns_inputs(assigns) do
~H"""
"""
end
defp cookie_path(socket) do
%{path: path} =
Enum.find(Map.get(socket, :router).__routes__, fn element ->
element[:plug] == Backpex.CookieController and element[:plug_opts] == :update
end)
path
end
@doc """
Renders pagination info about the current page.
"""
@doc type: :component
attr(:total, :integer, required: true, doc: "total number of items")
attr(:query_options, :map, required: true, doc: "query options")
def pagination_info(assigns) do
%{query_options: %{page: page, per_page: per_page}} = assigns
assigns =
assigns
|> assign(:from, (page - 1) * per_page + 1)
|> assign(:to, min(page * per_page, assigns.total))
~H"""
0} class="pr-2 text-sm text-gray-600">
<%= Backpex.translate({"Items %{from} to %{to}", %{from: @from, to: @to}}) %>
<%= "(#{@total} #{Backpex.translate("total")})" %>
"""
end
@doc """
Renders pagination buttons. You are required to provide a `:page` pattern in the URL. It will be replaced
with the corresponding page number.
"""
@doc type: :component
attr(:current_page, :integer, required: true, doc: "current page number")
attr(:total_pages, :integer, required: true, doc: "number of total pages")
attr(:path, :string, required: true, doc: "path to be used for page links")
def pagination(assigns) do
assigns =
assigns
|> assign(:pagination_items, pagination_items(assigns.current_page, assigns.total_pages))
~H"""
"""
end
@doc """
Renders the input fields for filters and search.
"""
@doc type: :component
attr(:live_resource, :any, required: true, doc: "module of the live resource")
attr(:searchable_fields, :list,
default: [],
doc: "The fields that can be searched. Here only used to hide the component when empty."
)
attr(:full_text_search, :string, default: nil, doc: "full text search column name")
attr(:query_options, :map, default: %{}, doc: "query options")
attr(:search_placeholder, :string, required: true, doc: "placeholder for the search input")
def resource_filters(assigns) do
~H"""
"""
end
defp selected?(selected_items, item), do: Enum.member?(selected_items, item)
defp active?(active_fields, name) do
active_fields
|> Keyword.get(name)
|> Map.get(:active)
end
defp resource_actions(assigns, resource_actions) do
Enum.filter(resource_actions, fn {key, _action} ->
LiveResource.can?(assigns, key, nil, assigns.live_resource)
end)
end
defp display_divider?(assigns) do
index_item_actions = index_item_actions(assigns.item_actions)
resource_actions = resource_actions(assigns, assigns.resource_actions)
Enum.any?(index_item_actions) &&
(Enum.any?(resource_actions) || LiveResource.can?(assigns, :new, nil, assigns.live_resource))
end
defp index_item_actions(item_actions) do
Enum.filter(item_actions, fn {_key, action} ->
action_on_index?(action)
end)
end
defp row_item_actions(item_actions) do
Enum.filter(item_actions, fn {_key, action} ->
action_on_row?(action)
end)
end
defp action_disabled?(assigns, action_key, items) do
Enum.filter(items, fn item ->
LiveResource.can?(assigns, action_key, item, assigns.live_resource)
end)
|> Enum.empty?()
end
defp action_on_row?(%{only: only}), do: :row in only
defp action_on_row?(%{except: except}), do: :row not in except
defp action_on_row?(_action), do: true
defp action_on_index?(%{only: only}), do: :index in only
defp action_on_index?(%{except: except}), do: :index not in except
defp action_on_index?(_action), do: true
@doc """
Renders an info block to indicate that no items are found.
"""
@doc type: :component
attr(:socket, :any, required: true)
attr(:live_resource, :atom, required: true, doc: "live resource module")
attr(:params, :map, required: true, doc: "query params")
attr(:singular_name, :string, required: true, doc: "singular name of the resource")
def empty_state(assigns) do
assigns =
assigns
|> assign(:search_active?, get_in(assigns, [:query_options, :search]) not in [nil, ""])
|> assign(:filter_active?, get_in(assigns, [:query_options, :filters]) != %{})
|> assign(:title, Backpex.translate({"No %{resources} found", %{resources: assigns.plural_name}}))
|> assign(:create_allowed, LiveResource.can?(assigns, :new, nil, assigns.live_resource))
~H"""
<.empty_state_content
:if={@search_active?}
title={@title}
subtitle={Backpex.translate("Try a different search term.")}
/>
<.empty_state_content
:if={not @search_active? and @filter_active?}
title={@title}
subtitle={Backpex.translate("Try a different filter setting or clear all filters.")}
/>
<.empty_state_content
:if={not @search_active? and not @filter_active?}
title={@title}
subtitle={
if @create_allowed,
do: Backpex.translate({"Get started by creating new %{resources}.", %{resources: assigns.plural_name}}),
else: ""
}
>
<.link :if={@create_allowed} patch={Router.get_path(@socket, @live_resource, @params, :new)}>
"""
end
attr(:title, :string, required: true, doc: "main title of the empty state info block")
attr(:subtitle, :string, required: true, doc: "subtitle of the empty state info block")
slot(:inner_block)
defp empty_state_content(assigns) do
~H"""
<%= @title %>
<%= @subtitle %>
<%= render_slot(@inner_block) %>
"""
end
@doc """
Renders the main resource index content.
"""
@doc type: :component
attr(:socket, :any, required: true)
attr(:live_resource, :any, required: true, doc: "module of the live resource")
attr(:params, :string, required: true, doc: "query parameters")
attr(:query_options, :map, default: %{}, doc: "query options")
attr(:total_pages, :integer, default: 1, doc: "amount of total pages")
attr(:resource_actions, :list,
default: [],
doc: "list of all resource actions provided by the resource configuration"
)
attr(:singular_name, :string, required: true, doc: "singular name of the resource")
attr(:orderable_fields, :list, default: [], doc: "list of orderable fields.")
attr(:items, :list, default: [], doc: "items that will be displayed in the table")
attr(:fields, :list,
default: [],
doc: "list of fields to be displayed in the table on index view"
)
def resource_index_main(assigns)
def resource_form_main(assigns)
@doc """
Renders a show card.
"""
@doc type: :component
attr(:socket, :any, required: true)
attr(:live_resource, :any, required: true, doc: "module of the live resource")
attr(:params, :string, required: true, doc: "query parameters")
attr(:item, :map, required: true, doc: "item that will be rendered on the card")
attr(:fields, :list, required: true, doc: "list of fields to be displayed on the card")
def resource_show_main(assigns)
@doc """
Renders a show panel.
"""
attr(:panel_fields, :list, required: true, doc: "list of fields to be rendered in the panel")
attr(:class, :string, default: "", doc: "extra class to be added")
attr(:label, :any, default: nil, doc: "optional label for the panel")
def show_panel(assigns) do
~H"""
"""
end
@doc """
Renders an edit panel.
"""
attr(:form, :any)
attr(:class, :string, default: "", doc: "extra class to be added")
attr(:panel_fields, :list, required: true, doc: "list of fields to be rendered in the panel")
attr(:label, :any, default: nil, doc: "optional label for the panel")
def edit_panel(assigns) do
~H"""
"""
end
@doc """
Renders the metrics area for the current resource.
"""
attr(:metrics, :list, default: [], doc: "list of metrics to be displayed")
def resource_metrics(assigns) do
%{metric_visibility: metric_visibility, live_resource: live_resource} = assigns
assigns =
assigns
|> assign(visible: Backpex.Metric.metrics_visible?(metric_visibility, live_resource))
~H"""
0 and @visible} class="items-center gap-4 lg:flex">
<%= for {_key, metric} <- @metrics do %>
<%= component(
&metric.module.render/1,
[metric: metric],
{__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}
) %>
<% end %>
"""
end
@doc """
Checks the given module if it has a `confirm/1` function exported or a list with fields.
"""
def has_modal?(module), do: function_exported?(module, :confirm, 1) or module.fields() != []
defp metric_toggle(assigns) do
visible = Backpex.Metric.metrics_visible?(assigns.metric_visibility, assigns.live_resource)
form =
%{"_resource" => assigns.live_resource, "_cookie_redirect_url" => assigns.current_url}
|> to_form(as: :toggle_metrics)
assigns =
assigns
|> assign(:visible, visible)
|> assign(:form, form)
~H"""