defmodule Backpex.LiveResource do @moduledoc ~S''' A LiveResource makes it easy to manage existing resources in your application. It provides extensive configuration options in order to meet everyone's needs. In connection with `Backpex.Components` you can build an individual admin dashboard on top of your application in minutes. > #### `use Backpex.LiveResource` {: .info} > > When you `use Backpex.LiveResource`, the `Backpex.LiveResource` module will set `@behavior Backpex.LiveResource`. Additionally it will create a LiveView based on the given configuration in order to create fully functional index, show, new and edit views for a resource. It will also insert fallback functions that can be overridden. ''' use Phoenix.LiveView import Backpex.HTML.Resource alias Backpex.Adapters.Ecto, as: EctoAdapter alias Backpex.Resource alias Backpex.ResourceAction alias Backpex.Router @options_schema [ adapter: [ doc: "The data layer adapter to use.", type: :atom, default: Backpex.Adapters.Ecto ], adapter_config: [ doc: "The configuration for the data layer. See corresponding adapter for possible configuration values.", type: :keyword_list, required: true ], primary_key: [ doc: "The primary key used for identifying items.", type: :atom, default: :id ], layout: [ doc: "Layout to be used by the LiveResource.", type: :mod_arg, required: true ], pubsub: [ doc: "PubSub configuration.", type: :keyword_list, required: true, keys: [ name: [ doc: "PubSub name of the project.", required: true, type: :atom ], event_prefix: [ doc: "The event prefix for Pubsub, to differentiate between events of different resources when subscribed to multiple resources.", required: true, type: :string ], topic: [ doc: "The topic for PubSub.", required: true, type: :string ] ] ], per_page_options: [ doc: "The page size numbers you can choose from.", type: {:list, :integer}, default: [15, 50, 100] ], per_page_default: [ doc: "The default page size number.", type: :integer, default: 15 ], init_order: [ doc: "Order that will be used when no other order options are given.", type: { :or, [ {:fun, 1}, map: [ by: [ doc: "The column used for ordering.", type: :atom ], direction: [ doc: "The order direction", type: :atom ] ] ] }, default: Macro.escape(%{by: :id, direction: :asc}) ], fluid?: [ type: :boolean, default: false ], full_text_search: [ type: :atom, default: nil ] ] @doc """ A list of [resource_actions](Backpex.ResourceAction.html) that may be performed on the given resource. """ @callback resource_actions() :: list() @doc """ A list of [item_actions](Backpex.ItemAction.html) that may be performed on (selected) items. """ @callback item_actions(default_actions :: list(map())) :: list() @doc """ A list of panels to group certain fields together. """ @callback panels() :: list() @doc """ A list of fields defining your resource. See `Backpex.Field`. """ @callback fields() :: list() @doc """ The singular name of the resource used for translations and titles. """ @callback singular_name() :: binary() @doc """ The plural name of the resource used for translations and titles. """ @callback plural_name() :: binary() @doc """ Replaces the default placeholder for the index search. """ @callback search_placeholder() :: binary() @doc """ An extra class to be added to table rows on the index view. """ @callback index_row_class(assigns :: map(), item :: map(), selected :: boolean(), index :: integer()) :: binary() | nil @doc """ The function that can be used to restrict access to certain actions. It will be called before performing an action and aborts when the function returns `false`. """ @callback can?(assigns :: map(), action :: atom(), item :: map() | nil) :: boolean() @doc """ The function that can be used to inject an ecto query. The query will be used when resources are being fetched. This happens on `index`, `edit` and `show` view. In most cases this function will be used to filter items on `index` view based on certain criteria, but it may also be used to join other tables on `edit` or `show` view. The function has to return an `Ecto.Query`. It is recommended to build your `item_query` on top of the incoming query. Otherwise you will likely get binding errors. """ @callback item_query(query :: Ecto.Query.t(), live_action :: atom(), assigns :: map()) :: Ecto.Query.t() @doc """ The function that can be used to add content to certain positions on Backpex views. It may also be used to overwrite content. The following actions are supported: `:index`, `:show` The following positions are supported for the `:index` action: `:page_title`, `:actions`, `:filters`, `:metrics` and `:main`. The following positions are supported for the `:show` action: `:page_title` and `:main`. In addition to this, content can be inserted between the main positions via the following extra spots: `:before_page_title`, `:before_actions`, `:before_filters`, `:before_metrics` and `:before_main`. """ @callback render_resource_slot(assigns :: map(), action :: atom(), position :: atom()) :: %Phoenix.LiveView.Rendered{} @doc """ A optional keyword list of [filters](Backpex.Filter.html) to be used on the index view. """ @callback filters() :: keyword() @doc """ A optional keyword list of [filters](Backpex.Filter.html) to be used on the index view. """ @callback filters(assigns :: map()) :: keyword() @doc """ A list of metrics shown on the index view of your resource. """ @callback metrics() :: keyword() @doc """ This function is executed when an item has been created. """ @callback on_item_created(socket :: Phoenix.LiveView.Socket.t(), item :: map()) :: Phoenix.LiveView.Socket.t() @doc """ This function is executed when an item has been updated. """ @callback on_item_updated(socket :: Phoenix.LiveView.Socket.t(), item :: map()) :: Phoenix.LiveView.Socket.t() @doc """ This function is executed when an item has been deleted. """ @callback on_item_deleted(socket :: Phoenix.LiveView.Socket.t(), item :: map()) :: Phoenix.LiveView.Socket.t() @doc """ This function navigates to the specified path when an item has been created or updated. Defaults to the previous resource path (index or edit). """ @callback return_to(socket :: Phoenix.LiveView.Socket.t(), assigns :: map(), action :: atom(), item :: map()) :: binary() @doc """ Customizes the label of the button for creating a new item. Defaults to "New %{resource}". """ @callback create_button_label() :: binary() @doc """ Customizes the message in the flash message when a resource has been created successfully. Defaults to "New %{resource} has been created successfully". """ @callback resource_created_message() :: binary() @doc """ Uses LiveResource in the current module to make it a LiveResource. use Backpex.LiveResource, adapter_config: [ schema: MyApp.User, repo: MyApp.Repo, update_changeset: &MyApp.User.update_changeset/3, create_changeset: &MyApp.User.create_changeset/3 ], layout: {MyAppWeb.LayoutView, :admin} # ... ## Options #{NimbleOptions.docs(@options_schema)} """ defmacro __using__(opts) do quote bind_quoted: [opts: opts, options_schema: @options_schema] do @before_compile Backpex.LiveResource @behaviour Backpex.LiveResource @resource_opts NimbleOptions.validate!(opts, options_schema) @resource_opts[:adapter].validate_config!(@resource_opts[:adapter_config]) use BackpexWeb, :html use Phoenix.LiveView, layout: @resource_opts[:layout] import Backpex.LiveResource import Phoenix.LiveView.Helpers import Ecto.Query alias Backpex.LiveResource def config(key), do: Keyword.fetch!(@resource_opts, key) def validated_fields, do: LiveResource.validated_fields(__MODULE__) @impl Phoenix.LiveView def mount(params, session, socket), do: LiveResource.mount(params, session, socket) @impl Phoenix.LiveView def handle_params(params, url, socket), do: LiveResource.handle_params(params, url, socket) @impl Phoenix.LiveView def render(assigns), do: LiveResource.render(assigns) @impl Backpex.LiveResource def can?(_assigns, _action, _item), do: true @impl Backpex.LiveResource def index_row_class(assigns, item, selected, index), do: nil @impl Backpex.LiveResource def fields, do: [] @impl Backpex.LiveResource def filters, do: [] @impl Backpex.LiveResource def filters(_assigns), do: filters() @impl Backpex.LiveResource def resource_actions, do: [] @impl Backpex.LiveResource def item_actions(default_actions), do: default_actions @impl Backpex.LiveResource def create_button_label, do: Backpex.translate({"New %{resource}", %{resource: singular_name()}}) @impl Backpex.LiveResource def resource_created_message, do: Backpex.translate({"New %{resource} has been created successfully.", %{resource: singular_name()}}) defoverridable can?: 3, fields: 0, filters: 0, filters: 1, resource_actions: 0, item_actions: 1, index_row_class: 4, create_button_label: 0, resource_created_message: 0 end end # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defmacro __before_compile__(_env) do quote do import Backpex.HTML.Layout import Backpex.HTML.Resource alias Backpex.LiveResource alias Backpex.Router @impl Phoenix.LiveView def handle_event(event, params, socket), do: LiveResource.handle_event(event, params, socket) @impl Phoenix.LiveView def handle_info(msg, socket), do: LiveResource.handle_info(msg, socket) @impl Backpex.LiveResource def panels, do: [] @impl Backpex.LiveResource def metrics, do: [] @impl Backpex.LiveResource def search_placeholder, do: Backpex.translate("Search") @impl Backpex.LiveResource def item_query(query, _live_action, _assigns), do: query @impl Backpex.LiveResource def on_item_created(socket, _item), do: socket @impl Backpex.LiveResource def on_item_updated(socket, _item), do: socket @impl Backpex.LiveResource def on_item_deleted(socket, _item), do: socket @impl Backpex.LiveResource def return_to(socket, assigns, _action, _item) do Map.get(assigns, :return_to, Router.get_path(socket, assigns.live_resource, %{}, :index)) end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :index, :page_title) do ~H""" <.main_title class="flex items-center justify-between"> <%= @plural_name %> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :index, :actions) do ~H""" <.resource_buttons {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :index, :filters) do ~H""" <.resource_filters {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :index, :metrics) do ~H""" <.resource_metrics {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :index, :main) do ~H""" <.resource_index_main {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :show, :page_title) do ~H""" <.main_title class="flex items-center justify-between"> <%= @singular_name %> <.link :if={@live_resource.can?(assigns, :edit, @item)} class="tooltip hover:z-30" data-tip={Backpex.translate("Edit")} aria-label={Backpex.translate("Edit")} patch={Router.get_path(@socket, @live_resource, @params, :edit, @item)} > """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :show, :main) do ~H""" <.resource_show_main {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :edit, :page_title) do ~H""" <.main_title class="mb-4"> <%= Backpex.translate({"Edit %{resource}", %{resource: @singular_name}}) %> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :new, :page_title) do ~H""" <.main_title class="mb-4"> <%= @create_button_label %> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :edit, :main) do ~H""" <.resource_form_main {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), :new, :main) do ~H""" <.resource_form_main {assigns} /> """ end @impl Backpex.LiveResource def render_resource_slot(var!(assigns), _action, _position), do: ~H"" end end @impl Phoenix.LiveView def mount(params, session, socket) do live_resource = socket.view pubsub = live_resource.config(:pubsub) subscribe_to_topic(socket, pubsub) # TODO: move these "config assigns" (and other global assigns) to where they are needed adapter_config = live_resource.config(:adapter_config) fluid? = live_resource.config(:fluid?) full_text_search = live_resource.config(:full_text_search) socket = socket |> assign(:live_resource, live_resource) |> assign(:schema, adapter_config[:schema]) |> assign(:repo, adapter_config[:repo]) |> assign(:singular_name, live_resource.singular_name()) |> assign(:plural_name, live_resource.plural_name()) |> assign(:create_button_label, live_resource.create_button_label()) |> assign(:resource_created_message, live_resource.resource_created_message()) |> assign(:search_placeholder, live_resource.search_placeholder()) |> assign(:panels, live_resource.panels()) |> assign(:fluid?, fluid?) |> assign(:full_text_search, full_text_search) |> assign_active_fields(session) |> assign_metrics_visibility(session) |> assign_filters_changed_status(params) {:ok, socket} end defp assign_active_fields(socket, session) do fields = socket.assigns.live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :index) saved_fields = get_in(session, ["backpex", "column_toggle", "#{socket.assigns.live_resource}"]) || %{} active_fields = Enum.map(fields, fn {name, %{label: label}} -> {name, %{ active: field_active?(name, saved_fields), label: label }} end) socket |> assign(:active_fields, active_fields) end defp assign_metrics_visibility(socket, session) do value = get_in(session, ["backpex", "metric_visibility"]) || %{} socket |> assign(metric_visibility: value) end defp assign_filters_changed_status(socket, params) do %{assigns: %{live_action: live_action}} = socket socket |> assign(:filters_changed, live_action == :index and params["filters_changed"] == "true") end defp field_active?(name, saved_fields) do case Map.get(saved_fields, Atom.to_string(name)) do "true" -> true "false" -> false _other -> true end end def assign_items(socket) do %{live_resource: live_resource, fields: fields} = socket.assigns criteria = build_criteria(socket.assigns) items = Resource.list(fields, socket.assigns, live_resource, criteria) assign(socket, :items, items) end defp maybe_assign_metrics(socket) do %{ assigns: %{ repo: repo, schema: schema, live_action: live_action, live_resource: live_resource, fields: fields, query_options: query_options, metric_visibility: metric_visibility } = assigns } = socket filters = active_filters(assigns) metrics = socket.assigns.live_resource.metrics() |> Enum.map(fn {key, metric} -> query = EctoAdapter.list_query( assigns, &socket.assigns.live_resource.item_query(&1, live_action, assigns), fields, search: search_options(query_options, fields, schema), filters: filter_options(query_options, filters) ) case Backpex.Metric.metrics_visible?(metric_visibility, live_resource) do true -> data = query |> Ecto.Query.exclude(:select) |> Ecto.Query.exclude(:preload) |> Ecto.Query.exclude(:group_by) |> metric.module.query(metric.select, repo) {key, Map.put(metric, :data, data)} _visible -> {key, metric} end end) socket |> assign(metrics: metrics) end @impl Phoenix.LiveView def render(%{live_action: action} = assigns) when action in [:show] do resource_show(assigns) end @impl Phoenix.LiveView def render(%{live_action: action} = assigns) when action in [:new, :edit] do resource_form(assigns) end @impl Phoenix.LiveView def render(assigns) do resource_index(assigns) end @impl Phoenix.LiveView def handle_params(params, _url, socket) do socket = socket |> assign(:params, params) |> apply_item_actions(socket.assigns.live_action) |> apply_action(socket.assigns.live_action) {:noreply, socket} end @doc """ Returns the fields of the given `Backpex.LiveResource` validated against each fields config schema. """ def validated_fields(live_resource) do live_resource.fields() |> Enum.map(fn {name, options} = field -> options.module.validate_config!(field, live_resource) |> Enum.into(%{}) |> then(&{name, &1}) end) end defp apply_action(socket, :index) do socket |> assign(:page_title, socket.assigns.plural_name) |> apply_index() |> assign(:item, nil) end defp apply_action(socket, :edit) do %{live_resource: live_resource, singular_name: singular_name} = socket.assigns fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :edit) primary_value = URI.decode(socket.assigns.params["backpex_id"]) item = Resource.get!(primary_value, socket.assigns, live_resource) if not live_resource.can?(socket.assigns, :edit, item), do: raise(Backpex.ForbiddenError) changeset_function = live_resource.config(:adapter_config)[:update_changeset] socket |> assign(:fields, fields) |> assign(:changeset_function, changeset_function) |> assign(:page_title, Backpex.translate({"Edit %{resource}", %{resource: singular_name}})) |> assign(:item, item) |> assign_changeset(changeset_function, item, fields, :edit) end defp apply_action(socket, :show) do %{live_resource: live_resource, singular_name: singular_name} = socket.assigns fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :show) primary_value = URI.decode(socket.assigns.params["backpex_id"]) item = Resource.get!(primary_value, socket.assigns, live_resource) if not live_resource.can?(socket.assigns, :show, item), do: raise(Backpex.ForbiddenError) socket |> assign(:page_title, singular_name) |> assign(:fields, fields) |> assign(:item, item) |> apply_show_return_to(item) end defp apply_action(socket, :new) do %{live_resource: live_resource, schema: schema, create_button_label: create_button_label} = socket.assigns if not live_resource.can?(socket.assigns, :new, nil), do: raise(Backpex.ForbiddenError) fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :new) empty_item = schema.__struct__() changeset_function = live_resource.config(:adapter_config)[:create_changeset] socket |> assign(:changeset_function, changeset_function) |> assign(:page_title, create_button_label) |> assign(:fields, fields) |> assign(:item, empty_item) |> assign_changeset(changeset_function, empty_item, fields, :new) end defp apply_action(socket, :resource_action) do %{live_resource: live_resource} = socket.assigns id = socket.assigns.params["backpex_id"] |> URI.decode() |> String.to_existing_atom() action = live_resource.resource_actions()[id] if not live_resource.can?(socket.assigns, id, nil), do: raise(Backpex.ForbiddenError) changeset_function = &action.module.changeset/3 item = action.module.base_schema(socket.assigns) socket |> assign(:page_title, ResourceAction.name(action, :title)) |> assign(:resource_action, action) |> assign(:resource_action_id, id) |> assign(:item, item) |> apply_index() |> assign(:changeset_function, changeset_function) |> assign_changeset(changeset_function, item, action.module.fields(), :resource_action) end defp apply_item_actions(socket, action) when action in [:index, :resource_action] do item_actions = Backpex.ItemAction.default_actions() |> socket.assigns.live_resource.item_actions() assign(socket, :item_actions, item_actions) end defp apply_item_actions(socket, _action), do: socket defp apply_index_return_to(socket) do %{live_resource: live_resource, params: params, query_options: query_options} = socket.assigns socket |> assign( :return_to, Router.get_path(socket, live_resource, params, :index, query_options) ) end defp apply_show_return_to(socket, item) do %{live_resource: live_resource, params: params} = socket.assigns socket |> assign(:return_to, Router.get_path(socket, live_resource, params, :show, item)) end defp apply_index(socket) do %{ live_resource: live_resource, schema: schema, params: params } = socket.assigns if not live_resource.can?(socket.assigns, :index, nil), do: raise(Backpex.ForbiddenError) fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :index) per_page_options = live_resource.config(:per_page_options) per_page_default = live_resource.config(:per_page_default) init_order = live_resource.config(:init_order) filters = active_filters(socket.assigns) valid_filter_params = get_valid_filters_from_params(params, filters, empty_filter_key()) count_criteria = [ search: search_options(params, fields, schema), filters: filter_options(valid_filter_params, filters) ] item_count = Resource.count(fields, socket.assigns, live_resource, count_criteria) per_page = params |> parse_integer("per_page", per_page_default) |> value_in_permitted_or_default(per_page_options, per_page_default) total_pages = calculate_total_pages(item_count, per_page) page = params |> parse_integer("page", 1) |> validate_page(total_pages) page_options = %{page: page, per_page: per_page} order_options = order_options_by_params(params, fields, init_order, socket.assigns) query_options = page_options |> Map.merge(order_options) |> maybe_put_search(params) |> Map.put(:filters, Map.get(valid_filter_params, "filters", %{})) socket |> assign(:item_count, item_count) |> assign(:query_options, query_options) |> assign(:init_order, init_order) |> assign(:total_pages, total_pages) |> assign(:per_page_options, per_page_options) |> assign(:filters, filters) |> assign(:orderable_fields, orderable_fields(fields)) |> assign(:searchable_fields, searchable_fields(fields)) |> assign(:resource_actions, live_resource.resource_actions()) |> assign(:action_to_confirm, nil) |> assign(:selected_items, []) |> assign(:select_all, false) |> assign(:fields, fields) |> assign(:changeset_function, live_resource.config(:adapter_config)[:update_changeset]) |> maybe_redirect_to_default_filters() |> assign_items() |> maybe_assign_metrics() |> apply_index_return_to() end defp assign_changeset(socket, changeset_function, item, fields, live_action) do metadata = Resource.build_changeset_metadata(socket.assigns) changeset = changeset_function.(item, default_attrs(live_action, fields, socket.assigns), metadata) assign(socket, :changeset, changeset) end defp default_attrs(:new, fields, %{schema: schema} = assigns) do Enum.reduce(fields, %{}, fn {name, %{default: default} = field_options} = field, attrs -> if field_options.module.association?(field) && schema.__schema__(:association, name).cardinality == :one do owner_key = schema.__schema__(:association, name).owner_key Map.put(attrs, owner_key, default.(assigns)) else Map.put(attrs, name, default.(assigns)) end _field, attrs -> attrs end) end defp default_attrs(:resource_action, fields, assigns) do Enum.reduce(fields, %{}, fn {name, %{default: default} = _field}, attrs -> Map.put(attrs, name, default.(assigns)) _field, attrs -> attrs end) end defp default_attrs(_live_action, _fields, _assigns), do: %{} defp maybe_redirect_to_default_filters(%{assigns: %{filters_changed: false}} = socket) do %{live_resource: live_resource, query_options: query_options, params: params, filters: filters} = socket.assigns filters_with_defaults = filters |> Enum.filter(fn {_key, filter_config} -> Map.has_key?(filter_config, :default) end) # redirect to default filters if no filters are set and defaults are available if Map.get(query_options, :filters) == %{} and Enum.count(filters_with_defaults) > 0 do default_filter_options = filters_with_defaults |> Enum.map(fn {key, filter_config} -> {key, filter_config.default} end) |> Enum.into(%{}, fn {key, value} -> {Atom.to_string(key), value} end) # redirect with updated query options options = Map.put(query_options, :filters, default_filter_options) to = Router.get_path(socket, live_resource, params, :index, options) push_navigate(socket, to: to) else socket end end defp maybe_redirect_to_default_filters(socket) do socket end defp maybe_put_search(query_options, %{"search" => search} = _params) when is_nil(search) or search == "", do: query_options defp maybe_put_search(query_options, %{"search" => search} = _params), do: Map.put(query_options, :search, search) defp maybe_put_search(query_options, _params), do: query_options @impl Phoenix.LiveView def handle_event("close-modal", _params, socket) do socket = socket |> push_patch(to: socket.assigns.return_to) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("item-action", %{"action-key" => key, "item-id" => item_id}, socket) do item = Enum.find(socket.assigns.items, fn item -> to_string(primary_value(socket, item)) == to_string(item_id) end) socket |> assign(selected_items: [item]) |> maybe_handle_item_action(key) end @impl Phoenix.LiveView def handle_event("item-action", %{"action-key" => key}, socket) do maybe_handle_item_action(socket, key) end @impl Phoenix.LiveView def handle_event("select-page-size", %{"select_per_page" => %{"value" => per_page}}, socket) do %{query_options: query_options, params: params} = socket.assigns per_page = String.to_integer(per_page) to = Router.get_path( socket, socket.assigns.live_resource, params, :index, Map.merge(query_options, %{per_page: per_page}) ) socket = push_patch(socket, to: to, replace: true) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("index-search", %{"index_search" => %{"value" => search_input}}, socket) do %{query_options: query_options, params: params} = socket.assigns to = Router.get_path( socket, socket.assigns.live_resource, params, :index, Map.merge(query_options, %{search: search_input}) ) socket = push_patch(socket, to: to, replace: true) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("change-filter", params, socket) do query_options = socket.assigns.query_options empty_filter_name = Atom.to_string(empty_filter_key()) filters = Map.get(query_options, :filters, %{}) |> Map.merge(params["filters"]) # Filter manually emptied filters and empty filter |> Enum.filter(fn {^empty_filter_name, _value} -> false {_filter, ""} -> false {_filter, %{"start" => "", "end" => ""}} -> false _filter_params -> true end) to = Router.get_path( socket, socket.assigns.live_resource, socket.assigns.params, :index, Map.put(query_options, :filters, filters) ) socket = socket |> assign(filters_changed: true) |> push_patch(to: to) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("clear-filter", %{"field" => field}, socket) do %{live_resource: live_resource, query_options: query_options, params: params} = socket.assigns new_query_options = Map.put( query_options, :filters, Map.get(query_options, :filters, %{}) |> Map.delete(field) |> maybe_put_empty_filter(empty_filter_key()) ) to = Router.get_path(socket, live_resource, params, :index, new_query_options) socket = push_patch(socket, to: to) |> assign(params: Map.merge(params, new_query_options)) |> assign(query_options: new_query_options) |> assign(filters_changed: true) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("filter-preset-selected", %{"field" => field, "preset-index" => preset_index} = _params, socket) do query_options = socket.assigns.query_options preset_index = String.to_integer(preset_index) field_atom = String.to_existing_atom(field) get_preset_values = socket.assigns |> get_in([:filters, field_atom, :presets]) |> Enum.at(preset_index) |> Map.get(:values) filters = Map.get(query_options, :filters, %{}) |> Map.put(field, get_preset_values.()) |> Map.drop([Atom.to_string(empty_filter_key())]) to = Router.get_path( socket, socket.assigns.live_resource, socket.assigns.params, :index, Map.put(query_options, :filters, filters) ) socket = socket |> assign(filters_changed: true) |> push_patch(to: to) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("update-selected-items", %{"id" => id}, socket) do selected_items = socket.assigns.selected_items item = Enum.find(socket.assigns.items, fn item -> to_string(primary_value(socket, item)) == to_string(id) end) updated_selected_items = if Enum.member?(selected_items, item) do List.delete(selected_items, item) else [item | selected_items] end select_all = length(updated_selected_items) == length(socket.assigns.items) socket = socket |> assign(:selected_items, updated_selected_items) |> assign(:select_all, select_all) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("toggle-item-selection", _params, socket) do select_all = not socket.assigns.select_all selected_items = if select_all do socket.assigns.items else [] end socket = socket |> assign(:select_all, select_all) |> assign(:selected_items, selected_items) {:noreply, socket} end @impl Phoenix.LiveView def handle_info({:put_assoc, {key, value} = _assoc}, socket) do changeset = Ecto.Changeset.put_assoc(socket.assigns.changeset, key, value) assocs = Map.get(socket.assigns, :assocs, []) |> Keyword.put(key, value) socket = socket |> assign(:assocs, assocs) |> assign(:changeset, changeset) {:noreply, socket} end @impl Phoenix.LiveView def handle_info({:put_embed, {key, value} = _assoc}, socket) do changeset = Ecto.Changeset.put_embed(socket.assigns.changeset, key, value) embeds = Map.get(socket.assigns, :embeds, []) |> Keyword.put(key, value) socket = socket |> assign(:embeds, embeds) |> assign(:changeset, changeset) {:noreply, socket} end @impl Phoenix.LiveView def handle_info({:update_changeset, changeset}, socket) do {:noreply, assign(socket, :changeset, changeset)} end @impl Phoenix.LiveView def handle_info({"backpex:" <> event, item}, socket) do event_prefix = socket.assigns.live_resource.config(:pubsub)[:event_prefix] ^event_prefix <> event_type = event handle_backpex_info({event_type, item}, socket) end @impl Phoenix.LiveView def handle_info(_msg, socket) do {:noreply, socket} end defp handle_backpex_info({"created", _item}, socket) when socket.assigns.live_action in [:index, :resource_action] do {:noreply, refresh_items(socket)} end defp handle_backpex_info({"deleted", item}, socket) when socket.assigns.live_action in [:index, :resource_action] do %{items: items} = socket.assigns if Enum.filter(items, &(to_string(primary_value(socket, &1)) == to_string(primary_value(socket, item)))) != [] do {:noreply, refresh_items(socket)} else {:noreply, socket} end end defp handle_backpex_info({"updated", item}, socket) when socket.assigns.live_action in [:index, :resource_action, :show] do {:noreply, update_item(socket, item)} end defp refresh_items(socket) do %{ live_resource: live_resource, schema: schema, params: params, fields: fields, query_options: query_options } = socket.assigns filters = active_filters(socket.assigns) valid_filter_params = get_valid_filters_from_params(params, filters, empty_filter_key()) count_criteria = [ search: search_options(params, fields, schema), filters: filter_options(valid_filter_params, filters) ] item_count = Resource.count(fields, socket.assigns, live_resource, count_criteria) %{page: page, per_page: per_page} = query_options total_pages = calculate_total_pages(item_count, per_page) new_query_options = Map.put(query_options, :page, validate_page(page, total_pages)) socket |> assign(:item_count, item_count) |> assign(:total_pages, total_pages) |> assign(:query_options, new_query_options) |> assign_items() |> maybe_assign_metrics() end defp update_item(socket, item) do %{live_resource: live_resource, live_action: live_action} = socket.assigns item_primary_value = primary_value(socket, item) item = Resource.get(item_primary_value, socket.assigns, live_resource) socket = cond do live_action in [:index, :resource_action] and item -> items = Enum.map(socket.assigns.items, &if(primary_value(socket, &1) == item_primary_value, do: item, else: &1)) assign(socket, :items, items) live_action == :show and item -> assign(socket, :item, item) true -> socket end socket end defp maybe_handle_item_action(socket, key) do key = String.to_existing_atom(key) action = socket.assigns.item_actions[key] items = socket.assigns.selected_items if Backpex.ItemAction.has_confirm_modal?(action) do open_action_confirm_modal(socket, action, key) else handle_item_action(socket, action, key, items) end end defp open_action_confirm_modal(socket, action, key) do socket = if Backpex.ItemAction.has_form?(action) do changeset_function = &action.module.changeset/3 base_schema = action.module.base_schema(socket.assigns) metadata = Resource.build_changeset_metadata(socket.assigns) changeset = changeset_function.(base_schema, %{}, metadata) socket |> assign(:item, base_schema) |> assign(:changeset, changeset) else socket |> assign(:changeset, %{}) end |> assign(:action_to_confirm, Map.put(action, :key, key)) {:noreply, socket} end defp handle_item_action(socket, action, key, items) do %{live_resource: live_resource} = socket.assigns items = Enum.filter(items, fn item -> live_resource.can?(socket.assigns, key, item) end) case action.module.handle(socket, items, %{}) do {:ok, socket} -> socket |> assign(action_to_confirm: nil) |> assign(selected_items: []) |> assign(select_all: false) {:noreply, 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 defp primary_value(socket, item) do primary_key = socket.assigns.live_resource.config(:primary_key) Map.get(item, primary_key) end @doc """ Subscribes to pubsub topic. """ def subscribe_to_topic(socket, name: name, topic: topic, event_prefix: _event_prefix) do if Phoenix.LiveView.connected?(socket) do Phoenix.PubSub.subscribe(name, topic) end end @doc """ Returns order options by params. ## Examples iex> Backpex.LiveResource.order_options_by_params(%{"order_by" => "field", "order_direction" => "asc"}, [field: %{}], %{by: :id, direction: :asc}, %{}) %{order_by: :field, order_direction: :asc} iex> Backpex.LiveResource.order_options_by_params(%{}, [field: %{}], %{by: :id, direction: :desc}, %{}) %{order_by: :id, order_direction: :desc} iex> Backpex.LiveResource.order_options_by_params(%{"order_by" => "field", "order_direction" => "asc"}, [field: %{orderable: false}], %{by: :id, direction: :asc}, %{}) %{order_by: :id, order_direction: :asc} """ def order_options_by_params(params, fields, init_order, assigns) do init_order = resolve_init_order(init_order, assigns) order_by = params |> Map.get("order_by") |> maybe_to_atom() |> value_in_permitted_or_default( orderable_fields(fields), Map.get(init_order, :by) ) order_direction = params |> Map.get("order_direction") |> maybe_to_atom() |> value_in_permitted_or_default( permitted_order_directions(), Map.get(init_order, :direction) ) %{order_by: order_by, order_direction: order_direction} end defp permitted_order_directions, do: ~w(asc desc)a @doc """ Returns all orderable fields. A field is orderable by default. ## Example iex> Backpex.LiveResource.orderable_fields([field1: %{orderable: true}]) [:field1] iex> Backpex.LiveResource.orderable_fields([field1: %{}]) [:field1] iex> Backpex.LiveResource.orderable_fields([field1: %{orderable: false}]) [] """ def orderable_fields(fields) do fields |> Keyword.filter(fn {_name, field} -> Map.get(field, :orderable, true) end) |> Enum.map(fn {name, _field_options} -> name end) end @doc """ Returns all searchable fields. A field is not searchable by default. ## Example iex> Backpex.LiveResource.searchable_fields([field1: %{searchable: true}]) [:field1] iex> Backpex.LiveResource.searchable_fields([field1: %{}]) [] iex> Backpex.LiveResource.searchable_fields([field1: %{searchable: false}]) [] """ def searchable_fields(fields) do fields |> Keyword.filter(fn {_name, field} -> Map.get(field, :searchable, false) end) |> Enum.map(fn {name, _field_options} -> name end) end @doc """ Returns filtered fields by a certain action. ## Example iex> Backpex.LiveResource.filtered_fields_by_action([field1: %{label: "Field1"}, field2: %{label: "Field2"}], %{}, :index) [field1: %{label: "Field1"}, field2: %{label: "Field2"}] iex> Backpex.LiveResource.filtered_fields_by_action([field1: %{label: "Field1", except: [:show]}, field2: %{label: "Field2"}], %{}, :show) [field2: %{label: "Field2"}] iex> Backpex.LiveResource.filtered_fields_by_action([field1: %{label: "Field1", only: [:index]}, field2: %{label: "Field2"}], %{}, :show) [field2: %{label: "Field2"}] """ def filtered_fields_by_action(fields, assigns, action) do fields |> Keyword.filter(fn {_name, field_options} -> can_view_field?(field_options, assigns) and filter_field_by_action(field_options, action) end) end defp can_view_field?(%{can?: can?} = _field_options, assigns), do: can?.(assigns) defp can_view_field?(_field_options, _assigns), do: true @doc """ Returns all search options. """ def search_options(params, fields, schema) do { Map.get( params, "search", Map.get(params, :search, "") ), fields |> Keyword.filter(fn {_name, field_options} -> Map.get(field_options, :searchable, false) end) |> Enum.map(fn {name, field_options} = field -> {name, Map.put(field_options, :queryable, field_options.module.schema(field, schema))} end) } end @doc """ Returns all filter options. """ def filter_options(%{"filters" => filters}, filter_configs), do: filter_options(%{filters: filters}, filter_configs) def filter_options(%{filters: ""}, _filter_configs), do: %{} def filter_options(%{filters: nil}, _filter_configs), do: %{} def filter_options(%{filters: filters}, filter_configs) do Enum.map(filters, fn {key, value} -> key_as_atom = String.to_existing_atom(key) %{ field: String.to_existing_atom(key), value: value, filter_config: filter_configs |> Keyword.get(key_as_atom) } end) end def filter_options(_no_filters_present, _filter_configs), do: %{} def empty_filter_key, do: :empty_filter @doc """ Checks whether a field is orderable or not. ## Examples iex> Backpex.LiveResource.orderable?({:name, %{orderable: true}}) true iex> Backpex.LiveResource.orderable?({:name, %{orderable: false}}) false iex> Backpex.LiveResource.orderable?({:name, %{}}) true iex> Backpex.LiveResource.orderable?(nil) false """ def orderable?(field) when is_nil(field), do: false def orderable?({_name, field_options}), do: Map.get(field_options, :orderable, true) defp build_criteria(assigns) do %{ schema: schema, fields: fields, filters: filters, query_options: query_options, init_order: init_order } = assigns field = Enum.find(fields, fn {name, _field_options} -> name == query_options.order_by end) order = if orderable?(field) do {field_name, field_options} = field %{ by: field_options.module.display_field(field), schema: field_options.module.schema(field, schema), direction: query_options.order_direction, field_name: field_name } else init_order |> resolve_init_order(assigns) |> Map.put(:schema, schema) end [ order: order, pagination: %{page: query_options.page, size: query_options.per_page}, search: search_options(query_options, fields, schema), filters: filter_options(query_options, filters) ] end @doc """ Resolves the initial order configuration. ## Examples iex> Backpex.LiveResource.resolve_init_order(%{by: :name, direction: :asc}, %{}) %{by: :name, direction: :asc} iex> Backpex.LiveResource.resolve_init_order(fn _ -> %{by: :age, direction: :desc} end, %{}) %{by: :age, direction: :desc} iex> Backpex.LiveResource.resolve_init_order(fn assigns -> fn _ -> %{by: assigns.sort_by, direction: :asc} end end, %{sort_by: :date}) ** (ArgumentError) init_order function should not return another function iex> Backpex.LiveResource.resolve_init_order(:invalid, %{}) ** (ArgumentError) init_order must be a map with keys :by and :direction, or a function returning such a map. Got: :invalid """ def resolve_init_order(init_order, assigns) when is_function(init_order, 1) do init_order = init_order.(assigns) # check if result is another function to prevent infinite loop if is_function(init_order, 1) do raise ArgumentError, "init_order function should not return another function" end resolve_init_order(init_order, assigns) end def resolve_init_order(%{by: _by, direction: _dir} = init_order, _assigns) do init_order end def resolve_init_order(init_order, _assigns) do raise ArgumentError, "init_order must be a map with keys :by and :direction, or a function returning such a map. Got: #{inspect(init_order)}" end @doc """ Parses integer text representation map value of the given key. If the map does not contain the given key or parsing fails the default value is returned. ## Examples iex> Backpex.LiveResource.parse_integer(%{number: "1"}, :number, 2) 1 iex> Backpex.LiveResource.parse_integer(%{number: "abc"}, :number, 1) 1 """ def parse_integer(map, key, default) do if Map.has_key?(map, key) do case map |> Map.get(key) |> Integer.parse() do {value, _reminder} -> value :error -> default end else default end end @doc """ Filters a field by a given action. It checks whether the field contains the only or except key and decides whether or not to keep the field. ## Examples iex> Backpex.LiveResource.filter_field_by_action(%{only: [:index]}, :index) true iex> Backpex.LiveResource.filter_field_by_action(%{only: [:edit]}, :index) false iex> Backpex.LiveResource.filter_field_by_action(%{except: [:edit]}, :index) true iex> Backpex.LiveResource.filter_field_by_action(%{except: [:index]}, :index) false """ def filter_field_by_action(field_options, action) do cond do Map.has_key?(field_options, :only) -> Enum.member?(field_options.only, action) Map.has_key?(field_options, :except) -> !Enum.member?(field_options.except, action) true -> true end end @doc """ Calculates the total amount of pages. ## Examples iex> Backpex.LiveResource.calculate_total_pages(1, 2) 1 iex> Backpex.LiveResource.calculate_total_pages(10, 10) 1 iex> Backpex.LiveResource.calculate_total_pages(20, 10) 2 iex> Backpex.LiveResource.calculate_total_pages(25, 6) 5 """ def calculate_total_pages(items_length, per_page), do: ceil(items_length / per_page) @doc """ Validates a page number. ## Examples iex> Backpex.LiveResource.validate_page(1, 5) 1 iex> Backpex.LiveResource.validate_page(-1, 5) 1 iex> Backpex.LiveResource.validate_page(6, 5) 5 """ def validate_page(_page, 0), do: 1 def validate_page(page, total_pages) do cond do page < 1 -> 1 page > total_pages -> total_pages true -> page end end @doc """ Checks whether the given value is in a list of permitted values. Otherwise return default value. ## Examples iex> Backpex.LiveResource.value_in_permitted_or_default(3, [1, 2, 3], 5) 3 iex> Backpex.LiveResource.value_in_permitted_or_default(3, [1, 2], 5) 5 """ def value_in_permitted_or_default(value, permitted, default) do if value in permitted, do: value, else: default end defp maybe_put_empty_filter(%{} = filters, empty_filter_key) when filters == %{} do Map.put(filters, Atom.to_string(empty_filter_key), true) end defp maybe_put_empty_filter(filters, _empty_filter_key) do filters end @doc """ Returns list of filter options from query options """ def get_filter_options(query_options) do query_options |> Map.get(:filters, %{}) |> Map.drop([Atom.to_string(empty_filter_key())]) end @doc """ Returns list of active filters. """ def active_filters(assigns) do filters = assigns.live_resource.filters(assigns) Enum.filter(filters, fn {key, option} -> empty_filter_key() != key and option.module.can?(assigns) end) end defp get_valid_filters_from_params(%{"filters" => filters} = params, valid_filters, empty_filter_key) do valid_filters = Keyword.put(valid_filters, empty_filter_key, %{}) filters = valid_filters |> Enum.reduce(%{}, fn {key, _val}, acc -> string_key = Atom.to_string(key) if Map.has_key?(filters, string_key) do value = Map.get(filters, string_key) Map.put(acc, string_key, value) else acc end end) Map.put(params, "filters", filters) end defp get_valid_filters_from_params(_params, _valid_filters, _empty_filter_key), do: %{} defp maybe_to_atom(nil), do: nil defp maybe_to_atom(value), do: String.to_existing_atom(value) end