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""" <.filter_badge :for={{key, value} <- @filter_options} key={key} value={value} filter={Keyword.get(@filters, String.to_existing_atom(key))} /> """ end defp filter_badge(assigns) do ~H"""
<%= Map.get(@filter, :label, @filter.module.label()) %>
<%= component( &@filter.module.render/1, [value: @value], {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} ) %>
""" end defp index_filter_forms(assigns) do ~H"""
<% value = Map.get(@filter_options, Atom.to_string(field), nil) %> <.form :let={f} for={to_form(%{}, as: :filters)} phx-change="change-filter" phx-submit="change-filter">
<%= Map.get(filter, :label, filter.module.label()) %>
<.maybe_clear_button field={field} value={value} />
<%= component( &filter.module.render_form/1, [field: field, value: value, form: f], {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} ) %>
<.filter_presets field={field} presets={Map.get(filter, :presets)} />
""" 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"""
<.pagination_item :for={%{type: type, number: number} <- @pagination_items} class="join-item text-xs" type={type} number={number} current_page={@current_page} path={@path} />
""" end attr(:path, :string, required: true) attr(:current_page, :integer, required: true) attr(:type, :atom, required: true) attr(:number, :integer, default: nil, required: false) attr(:class, :string, default: nil) defp pagination_item(%{type: :number} = assigns) do pagination_link = get_pagination_link(assigns.path, assigns.number) assigns = assign(assigns, :href, pagination_link) ~H""" <%= if @current_page == @number do %> <% else %> <.link href={@href}> <% end %> """ end defp pagination_item(%{type: :prev} = assigns) do pagination_link = get_pagination_link(assigns.path, assigns.current_page - 1) assigns = assign(assigns, :href, pagination_link) ~H""" <.link href={@href}> """ end defp pagination_item(%{type: :next} = assigns) do pagination_link = get_pagination_link(assigns.path, assigns.current_page + 1) assigns = assign(assigns, :href, pagination_link) ~H""" <.link href={@href}> """ end defp pagination_item(%{type: :placeholder} = assigns) do ~H""" """ end defp get_pagination_link(path, page), do: String.replace(path, ":page", page |> Integer.to_string()) @doc """ Creates a list of pagination items based on the current page and the total number of pages. A maximum of five pages will be displayed. ### Example iex> Backpex.HTML.Resource.pagination_items(1, 1) [%{type: :number, number: 1}] iex> Backpex.HTML.Resource.pagination_items(1, 2) [%{type: :number, number: 1}, %{type: :number, number: 2}, %{type: :next, number: nil}] iex> Backpex.HTML.Resource.pagination_items(2, 2) [%{type: :prev, number: nil}, %{type: :number, number: 1}, %{type: :number, number: 2}] iex> Backpex.HTML.Resource.pagination_items(2, 8) [%{type: :prev, number: nil}, %{type: :number, number: 1}, %{type: :number, number: 2}, %{type: :number, number: 3}, %{type: :number, number: 4}, %{type: :placeholder, number: nil}, %{type: :number, number: 8}, %{type: :next, number: nil}] iex> Backpex.HTML.Resource.pagination_items(5, 10) [%{type: :prev, number: nil}, %{type: :number, number: 1}, %{type: :placeholder, number: nil}, %{type: :number, number: 4}, %{type: :number, number: 5}, %{type: :number, number: 6}, %{type: :placeholder, number: nil}, %{type: :number, number: 10}, %{type: :next, number: nil}] iex> Backpex.HTML.Resource.pagination_items(9, 10) [%{type: :prev, number: nil}, %{type: :number, number: 1}, %{type: :placeholder, number: nil}, %{type: :number, number: 7}, %{type: :number, number: 8}, %{type: :number, number: 9}, %{type: :number, number: 10}, %{type: :next, number: nil}] """ def pagination_items(_current_page, total_pages) when total_pages <= 0, do: [%{type: :number, number: 1}] def pagination_items(current_page, total_pages) do Enum.reduce(1..total_pages, [], fn page, acc -> add_pagination_item(acc, current_page, total_pages, page) end) |> Enum.reverse() |> maybe_add_prev(current_page, total_pages) |> maybe_add_next(current_page, total_pages) end # Always display first and last page defp add_pagination_item(acc, _current_page, total_pages, page) when page == 1 or page == total_pages do [%{type: :number, number: page} | acc] end # Display page when current page and page are close to first page defp add_pagination_item(acc, current_page, _total_pages, page) when current_page < 4 and page < 5 do [%{type: :number, number: page} | acc] end # Display page when current page and page are close to last page defp add_pagination_item(acc, current_page, total_pages, page) when total_pages - current_page < 3 and total_pages - page < 4 do [%{type: :number, number: page} | acc] end # Display surrounding pages if current page and page are in the middle of all pages defp add_pagination_item(acc, current_page, total_pages, page) when total_pages - current_page >= 3 and current_page >= 4 and Kernel.abs(current_page - page) == 1 do [%{type: :number, number: page} | acc] end # Always display current page (page == current_page) defp add_pagination_item(acc, current_page, _total_pages, page) when page == current_page do [%{type: :number, number: page} | acc] end # Do not display consecutive placeholders defp add_pagination_item([%{type: :placeholder} | _rest] = acc, _current_page, _total_pages, _page) do acc end # Display placeholder for pages that are not shown defp add_pagination_item(acc, _current_page, _total_pages, _page) do [%{type: :placeholder, number: nil} | acc] end defp maybe_add_prev(pages, current_page, _total_pages) when current_page > 1 do [%{type: :prev, number: nil} | pages] end defp maybe_add_prev(pages, _current_page, _total_pages) do pages end defp maybe_add_next(pages, current_page, total_pages) when current_page < total_pages do pages |> Enum.reverse() |> Kernel.then(fn pages -> [%{type: :next, number: nil} | pages] end) |> Enum.reverse() end defp maybe_add_next(pages, _current_page, _total_pages) do pages end @doc """ Renders a select per page button. """ @doc type: :component attr(:options, :list, required: true, doc: "A list of per page options.") attr(:query_options, :map, default: %{}, doc: "The query options.") attr(:class, :string, default: "", doc: "Extra class to be added to the select.") def select_per_page(assigns) do form = to_form(%{}, as: :select_per_page) assigns = assigns |> assign(:form, form) |> assign(:selected, assigns.query_options.per_page) ~H""" <.form for={@form} class={@class} phx-change="select-page-size" phx-submit="select-page-size"> """ end @doc """ Renders a button group with create and resource action buttons. """ @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(: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") def resource_buttons(assigns) do ~H"""
<.link :if={LiveResource.can?(assigns, :new, nil, @live_resource)} patch={Router.get_path(@socket, @live_resource, @params, :new)} > <.link :for={{key, action} <- resource_actions(assigns, @resource_actions)} patch={Router.get_path(@socket, @live_resource, @params, :resource_action, key, @query_options)} >
""" 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"""
<.metric_toggle {assigns} /> <.index_search_form searchable_fields={@searchable_fields} full_text_search={@full_text_search} value={Map.get(@query_options, :search, "")} placeholder={@search_placeholder} /> <.index_filter live_resource={@live_resource} filter_options={LiveResource.get_filter_options(@live_resource, @query_options)} filters={LiveResource.get_active_filters(@live_resource, assigns)} />
""" 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"""

<%= @label %>

<.field_container> <:label> <.input_label text={label} /> <.resource_field name={name} {assigns} />
""" 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"""

<%= @label %>
<.resource_form_field :for={{name, _field_options} <- @panel_fields} name={name} form={@form} {assigns} />
""" 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"""
0}> <.form method="POST" for={@form} action={cookie_path(@socket)}>
""" end defp index_column_class(_assigns, %{index_column_class: class} = _field_options) when is_binary(class) do class end defp index_column_class(assigns, %{index_column_class: class} = _field_options) when is_function(class) do class.(assigns) end defp index_column_class(_assign, _field_options) do nil end defp align(field_options) do class = field_options |> Map.get(:align, :left) |> align_class() "flex #{class}" end defp index_row_class(assigns, item, selected, index) do base_class = if selected, do: "bg-gray-50", else: "bg-white" extra_class = assigns.live_resource.index_row_class(assigns, item, selected, index) [base_class, extra_class] end defp align_class(:left), do: "justify-start text-left" defp align_class(:right), do: "justify-end text-right" defp align_class(:center), do: "justify-center text-center" defp align_class(_alignment), do: "justify-start text-left" defp toggle_order_direction(:asc), do: :desc defp toggle_order_direction(:desc), do: :asc defp shadow_sm_left, do: "box-shadow: -1px 0 2px 0 rgba(0,0,0,0.05)" end